mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-25 22:35:49 +00:00
plan oxidation
- add the drivers - add the uniffi build toolchain - create the plan document
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# uniffi-generated MatrixRTC SDK bindings, vendored by scripts/sync-matrix-rtc-sdk.sh
|
||||||
|
src/matrix-rtc-sdk/generated/** linguist-generated=true
|
||||||
|
src/matrix-rtc-sdk/generated/**/*.wasm binary
|
||||||
+6
-1
@@ -2,5 +2,10 @@
|
|||||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"sortPackageJson": false,
|
"sortPackageJson": false,
|
||||||
"ignorePatterns": ["pnpm-lock.yaml", "node_modules", "dist"]
|
"ignorePatterns": [
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"src/matrix-rtc-sdk/generated"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"builtin": true
|
"builtin": true
|
||||||
},
|
},
|
||||||
|
// uniffi-generated bindings and wasm-bindgen glue, vendored by
|
||||||
|
// scripts/sync-matrix-rtc-sdk.sh; not ours to lint
|
||||||
|
"ignorePatterns": ["src/matrix-rtc-sdk/generated/**"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"element-call/copyright-header": [
|
"element-call/copyright-header": [
|
||||||
"error",
|
"error",
|
||||||
|
|||||||
@@ -0,0 +1,730 @@
|
|||||||
|
# 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–C8; C1 was reverted in favour of slot opening; C11 and C12 done;
|
||||||
|
C9 and C10 are pending) sit 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. Until it exists, the generated
|
||||||
|
bindings are vendored (§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`, `DisconnectedBanner` |
|
||||||
|
|
||||||
|
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, StickyEvents, StateEvents}` today; `StickyEvents`
|
||||||
|
is removed by C9, leaving `Off` (spec MSC4143) and `StateEvents` (MSC3401).
|
||||||
|
- 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.getCapabilities()` |
|
||||||
|
|
||||||
|
**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. |
|
||||||
|
|
||||||
|
No crate work is deferred: `update_application` is C11.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Target architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
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 CallParticipation for lobby → call → ended │
|
||||||
|
│ CallParticipation (src/state/rtc/CallParticipation.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
|
||||||
|
`Behavior`s internally (`src/driver/observe.ts`).
|
||||||
|
|
||||||
|
```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;
|
||||||
|
getCapabilities(): Promise<DriverCapabilities>;
|
||||||
|
/** Free-form diagnostics for rageshakes (crypto version, sync state, …). */
|
||||||
|
getDiagnostics?(): Promise<Record<string, string>>;
|
||||||
|
}
|
||||||
|
export interface DriverCapabilities {
|
||||||
|
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 `CallParticipation` (Element Call's RxJS view of the manager)
|
||||||
|
|
||||||
|
Naming: _participation_ is the crate's FFI concept (`FfiParticipationManager`,
|
||||||
|
`FfiParticipationConfig`); `CallParticipation` is Element Call's RxJS wrapper
|
||||||
|
over it.
|
||||||
|
|
||||||
|
`src/state/rtc/CallParticipation.ts`, a class taking the scope in its constructor:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
new CallParticipation(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
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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, `CallParticipation.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). Until C9 lands the
|
||||||
|
code still maps `Matrix_2_0` to the crate's `StickyEvents`; `compatForMode` is
|
||||||
|
the one place that changes.
|
||||||
|
|
||||||
|
### 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 `CallParticipation` (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 → `getCapabilities()`; custom LiveKit
|
||||||
|
URL validation → `driver.getLivekitToken(...)`.
|
||||||
|
- `DisconnectedBanner` → `HomeserverUnreachable` in `status$` (C12).
|
||||||
|
- `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
|
||||||
|
`CallParticipation`, 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`). `getCapabilities().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). Until C5 lands the
|
||||||
|
driver still carries Element Call's probe and JWT delegation; both go then.
|
||||||
|
- 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. **Vendored bindings.** `src/matrix-rtc-sdk/generated/` holds `matrix_rtc.ts`,
|
||||||
|
`matrix_rtc-ffi.ts`, `wasm-bindgen/index.js`, `index_bg.wasm` and a
|
||||||
|
hand-written `wasm-bindgen/index.d.ts` (no `allowJs` in `tsconfig.json`),
|
||||||
|
synced by `scripts/sync-matrix-rtc-sdk.sh` (runs `ubrn build web` in the
|
||||||
|
draft **without** the `runtime-probe` feature, copies). Committed so CI
|
||||||
|
works. `@ubjs/core` becomes a dependency. The wasm is ~6.5 MB unoptimised.
|
||||||
|
**Assumption:** committing the binary is acceptable for the draft phase.
|
||||||
|
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. vitest reads the file from disk; wasm boot is
|
||||||
|
**lazy** (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). Element Call keeps no probe and no
|
||||||
|
delegation code.
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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/**`,
|
||||||
|
`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`), `knip.ts` (`ignore` for `generated/**`),
|
||||||
|
`.oxlintrc.json` `ignorePatterns`, `.oxfmtrc.json` ignore,
|
||||||
|
`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 — `CallParticipation` ☑
|
||||||
|
|
||||||
|
- `src/state/rtc/CallParticipation.ts`, `joinParams.ts`, `transportIntent.ts`,
|
||||||
|
`errors.ts` (cause → `ElementCallError`).
|
||||||
|
- `CallParticipation.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 ☐
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### S4 — React tree, two slices ☐
|
||||||
|
|
||||||
|
- **S4a** views/hooks/settings on the driver: `CallView.tsx` (owns
|
||||||
|
`CallParticipation`), `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`,
|
||||||
|
`DisconnectedBanner.tsx`, `analytics/PosthogEvents.ts`, `controls.ts`, and a
|
||||||
|
first `CallView.stories.tsx` (lobby, in call, ended) driven by
|
||||||
|
`MockMatrixDriver`.
|
||||||
|
- **S4b** reactions and notifications: `useReactionsSender.tsx`,
|
||||||
|
`ReactionsReader` keyed by `memberId`, `CallNotificationLifecycle` sending
|
||||||
|
through the driver; tests incl. a membership re-send mid-call.
|
||||||
|
|
||||||
|
### S5 — hosts ☐
|
||||||
|
|
||||||
|
- `component/index.tsx`, `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`.
|
||||||
|
- `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 | | | | | | | ● | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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); `CallParticipation` 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.
|
||||||
@@ -26,6 +26,15 @@ export default {
|
|||||||
// This is a shell built-in.
|
// This is a shell built-in.
|
||||||
"printf",
|
"printf",
|
||||||
],
|
],
|
||||||
|
// uniffi-generated bindings (scripts/sync-matrix-rtc-sdk.sh): every
|
||||||
|
// export the crate has, most of them unused here by design
|
||||||
|
ignore: [
|
||||||
|
"src/matrix-rtc-sdk/generated/**",
|
||||||
|
// The host-facing driver seam, landed ahead of its consumers (the
|
||||||
|
// `CallParticipation` layer and the hosts, see element-call-oxidation-plan.md)
|
||||||
|
"src/driver/**",
|
||||||
|
"src/state/rtc/**",
|
||||||
|
],
|
||||||
ignoreFiles: [
|
ignoreFiles: [
|
||||||
"scripts/.pnpmfile.cjs",
|
"scripts/.pnpmfile.cjs",
|
||||||
// Deliberately added prior to any component or business logic
|
// Deliberately added prior to any component or business logic
|
||||||
|
|||||||
@@ -110,6 +110,8 @@
|
|||||||
"membership_manager": "Membership Manager Error",
|
"membership_manager": "Membership Manager Error",
|
||||||
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consecutive failed network requests.",
|
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consecutive failed network requests.",
|
||||||
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
|
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
|
||||||
|
"no_open_slot": "No call to join here",
|
||||||
|
"no_open_slot_description": "Nobody has started a call in this room yet, and you do not have permission to start one. Ask a room admin to start the call, or to allow you to start calls.",
|
||||||
"open_elsewhere": "Opened in another tab",
|
"open_elsewhere": "Opened in another tab",
|
||||||
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
|
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
|
||||||
"peer_connection_timeout": "Connection timeout",
|
"peer_connection_timeout": "Connection timeout",
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@types/sdp-transform": "^2.4.5",
|
"@types/sdp-transform": "^2.4.5",
|
||||||
"@typescript-eslint/utils": "^8.61.0",
|
"@typescript-eslint/utils": "^8.61.0",
|
||||||
|
"@ubjs/core": "0.31.0-5",
|
||||||
"@use-gesture/react": "^10.2.11",
|
"@use-gesture/react": "^10.2.11",
|
||||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||||
"@vector-im/compound-web": "^10.0.0",
|
"@vector-im/compound-web": "^10.0.0",
|
||||||
|
|||||||
Generated
+8
@@ -127,6 +127,9 @@ importers:
|
|||||||
'@typescript-eslint/utils':
|
'@typescript-eslint/utils':
|
||||||
specifier: ^8.61.0
|
specifier: ^8.61.0
|
||||||
version: 8.69.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
|
version: 8.69.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
|
||||||
|
'@ubjs/core':
|
||||||
|
specifier: 0.31.0-5
|
||||||
|
version: 0.31.0-5
|
||||||
'@use-gesture/react':
|
'@use-gesture/react':
|
||||||
specifier: ^10.2.11
|
specifier: ^10.2.11
|
||||||
version: 10.3.1(react@19.2.8)
|
version: 10.3.1(react@19.2.8)
|
||||||
@@ -3218,6 +3221,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==}
|
resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@ubjs/core@0.31.0-5':
|
||||||
|
resolution: {integrity: sha512-oRBRtyYOhaodiOY3rLMZFgAHGFMmD44XefMpsAx4Ja0/rO+45kVdQB1VZsblQMLYxP965i3IHm56eymgo3I5eQ==}
|
||||||
|
|
||||||
'@ungap/structured-clone@1.3.3':
|
'@ungap/structured-clone@1.3.3':
|
||||||
resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
|
resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
|
||||||
|
|
||||||
@@ -8686,6 +8692,8 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.69.0
|
'@typescript-eslint/types': 8.69.0
|
||||||
eslint-visitor-keys: 5.0.1
|
eslint-visitor-keys: 5.0.1
|
||||||
|
|
||||||
|
'@ubjs/core@0.31.0-5': {}
|
||||||
|
|
||||||
'@ungap/structured-clone@1.3.3': {}
|
'@ungap/structured-clone@1.3.3': {}
|
||||||
|
|
||||||
'@use-gesture/core@10.3.1': {}
|
'@use-gesture/core@10.3.1': {}
|
||||||
|
|||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Copyright 2026 Element Creations Ltd.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
# Please see LICENSE in the repository root for full details.
|
||||||
|
#
|
||||||
|
# Vendors the matrix-rtc crate's uniffi web bindings into
|
||||||
|
# src/matrix-rtc-sdk/generated/. Until the crate ships as an npm package this
|
||||||
|
# is how Element Call picks up a new build of it: build it there, copy the
|
||||||
|
# generated TypeScript, the wasm-bindgen glue and the wasm here.
|
||||||
|
#
|
||||||
|
# scripts/sync-matrix-rtc-sdk.sh [path-to-MatrixSdkArchitectureDraft] [--no-build]
|
||||||
|
#
|
||||||
|
# The build uses web-test-app/ubrn.element-call.config.yaml (feature `uniffi`
|
||||||
|
# only, release profile, its own output directory so the app's test build is
|
||||||
|
# left alone). Pass --no-build to copy whatever was built last.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DRAFT="${1:-../matrix-rust-rtc/MatrixSdkArchitectureDraft}"
|
||||||
|
BUILD=1
|
||||||
|
for arg in "$@"; do
|
||||||
|
[[ "$arg" == "--no-build" ]] && BUILD=0
|
||||||
|
done
|
||||||
|
|
||||||
|
APP="$DRAFT/web-test-app"
|
||||||
|
GENERATED="$APP/src/generated-element-call"
|
||||||
|
DEST="$(cd "$(dirname "$0")/.." && pwd)/src/matrix-rtc-sdk/generated"
|
||||||
|
|
||||||
|
if [[ ! -f "$APP/ubrn.element-call.config.yaml" ]]; then
|
||||||
|
echo "No web-test-app/ubrn.element-call.config.yaml under $DRAFT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$BUILD" == 1 ]]; then
|
||||||
|
(cd "$APP" && npx ubrn build web --config ubrn.element-call.config.yaml --release)
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DEST/wasm-bindgen"
|
||||||
|
cp "$GENERATED/matrix_rtc.ts" "$GENERATED/matrix_rtc-ffi.ts" "$DEST/"
|
||||||
|
cp "$GENERATED/wasm-bindgen/index.js" "$GENERATED/wasm-bindgen/index_bg.wasm" "$DEST/wasm-bindgen/"
|
||||||
|
|
||||||
|
REV="$(git -C "$DRAFT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||||
|
DIRTY="$(git -C "$DRAFT" status --porcelain 2>/dev/null | grep -q . && echo '-dirty' || true)"
|
||||||
|
cat > "$DEST/VERSION" <<VERSION
|
||||||
|
matrix-rtc (MatrixSdkArchitectureDraft) ${REV}${DIRTY}
|
||||||
|
built $(date -u +%Y-%m-%dT%H:%M:%SZ) by scripts/sync-matrix-rtc-sdk.sh
|
||||||
|
VERSION
|
||||||
|
|
||||||
|
echo "Synced matrix-rtc bindings from $DRAFT ($REV$DIRTY) into $DEST"
|
||||||
|
ls -la "$DEST" "$DEST/wasm-bindgen"
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Element Call needs from a Matrix client beyond MatrixRTC.
|
||||||
|
*
|
||||||
|
* A host implements this once per room next to its {@link RtcMatrixDriver}.
|
||||||
|
* It covers what the crate deliberately leaves to the application: who is
|
||||||
|
* in the room and what they are called, sending a reaction, showing an
|
||||||
|
* avatar, the user's own profile, and what the deployment can do. Homeserver
|
||||||
|
* connectivity is *not* here: the crate needs it too, so it is part of the
|
||||||
|
* RTC driver and reaches Element Call as a participation impairment.
|
||||||
|
*
|
||||||
|
* Deliberately framework-neutral: plain values, promises and
|
||||||
|
* `subscribeX(listener)` pairs that return an unsubscribe function, the
|
||||||
|
* same shape as the crate's sinks. No RxJS crosses this boundary; Element
|
||||||
|
* Call wraps subscriptions into behaviors itself (see `observe.ts`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Removes the listener it was returned for. */
|
||||||
|
export type Unsubscribe = () => void;
|
||||||
|
|
||||||
|
export interface DriverCapabilities {
|
||||||
|
/** The homeserver accepts sticky events (MSC4354). */
|
||||||
|
stickyEvents: boolean;
|
||||||
|
/**
|
||||||
|
* The events the RTC driver emits carry real decryption metadata. A
|
||||||
|
* widget client receives events already decrypted by its host and can
|
||||||
|
* only report the device the content *claims*.
|
||||||
|
*/
|
||||||
|
verifiedEventOrigins: boolean;
|
||||||
|
/** The RTC driver can say whether a sending device is cross-signed (MSC4153). */
|
||||||
|
crossSigningVerdicts: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomInfo {
|
||||||
|
name: string;
|
||||||
|
canonicalAlias: string | null;
|
||||||
|
/** An `mxc://` URL. */
|
||||||
|
avatarUrl: string | null;
|
||||||
|
/** The `m.room.join_rules` value, `null` while unknown. */
|
||||||
|
joinRule: string | null;
|
||||||
|
/** Whether the room has an `m.room.encryption` state event. */
|
||||||
|
encrypted: boolean;
|
||||||
|
/**
|
||||||
|
* Whether this user may send the MatrixRTC slot state event
|
||||||
|
* (`org.matrix.msc4143.rtc.slot`). A call needs an open slot; the client
|
||||||
|
* that starts a call opens one, which takes the power level for it.
|
||||||
|
*/
|
||||||
|
canOpenSlot: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomMemberProfile {
|
||||||
|
userId: string;
|
||||||
|
displayName: string | null;
|
||||||
|
/** An `mxc://` URL. */
|
||||||
|
avatarUrl: string | null;
|
||||||
|
membership: "join" | "invite";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room metadata and the profiles of the people in it.
|
||||||
|
*
|
||||||
|
* Call members' names and avatars do not come from here: the crate reads
|
||||||
|
* `m.room.member` itself and puts them on each membership. This roster is
|
||||||
|
* for the people who are in the room but not (yet) in the call — the person
|
||||||
|
* being rung, and how many others there are.
|
||||||
|
*/
|
||||||
|
export interface RoomDriver {
|
||||||
|
getRoomInfo(): RoomInfo;
|
||||||
|
subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe;
|
||||||
|
/** Joined and invited members of the room, in or out of the call. */
|
||||||
|
getRoomMembers(): RoomMemberProfile[];
|
||||||
|
subscribeRoomMembers(
|
||||||
|
listener: (members: RoomMemberProfile[]) => void,
|
||||||
|
): Unsubscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A decrypted room event, as far as a call needs to know it. */
|
||||||
|
export interface TimelineEvent {
|
||||||
|
eventId: string;
|
||||||
|
type: string;
|
||||||
|
sender: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
originServerTs: number;
|
||||||
|
/** Set on an `m.room.redaction`. */
|
||||||
|
redacts?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Application events in the room: reactions, hand raises, notifications. */
|
||||||
|
export interface TimelineDriver {
|
||||||
|
sendRoomEvent(
|
||||||
|
eventType: string,
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
): Promise<{ eventId: string }>;
|
||||||
|
redactEvent(eventId: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Live room events (not sticky ones — those reach the crate through its
|
||||||
|
* own sink), decrypted, redactions included, local echoes excluded.
|
||||||
|
*/
|
||||||
|
subscribeTimeline(listener: (event: TimelineEvent) => void): Unsubscribe;
|
||||||
|
/**
|
||||||
|
* Events already known that relate to `eventId` with the given relation
|
||||||
|
* type and event type — how a late joiner learns of a raised hand.
|
||||||
|
*/
|
||||||
|
getRelatedEvents(
|
||||||
|
eventId: string,
|
||||||
|
relType: string,
|
||||||
|
eventType: string,
|
||||||
|
): TimelineEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OwnProfile {
|
||||||
|
displayName: string | null;
|
||||||
|
/** An `mxc://` URL. */
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's own profile. Editing is optional: a host may not allow it. */
|
||||||
|
export interface ProfileDriver {
|
||||||
|
getOwnProfile(): OwnProfile;
|
||||||
|
subscribeOwnProfile(listener: (profile: OwnProfile) => void): Unsubscribe;
|
||||||
|
setDisplayName?(name: string): Promise<void>;
|
||||||
|
setAvatar?(file: Blob): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaDriver {
|
||||||
|
/**
|
||||||
|
* A URL an `<img>` can show for an `mxc://` thumbnail — possibly a `blob:`
|
||||||
|
* URL the driver fetched with its credentials — or null when the media
|
||||||
|
* cannot be resolved.
|
||||||
|
*/
|
||||||
|
thumbnailUrl(
|
||||||
|
mxcUrl: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
resizeMethod: "crop" | "scale",
|
||||||
|
): Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything Element Call asks of a Matrix client beyond MatrixRTC, bound to
|
||||||
|
* one room. One object, sliced into the capabilities above the way the crate
|
||||||
|
* slices its own driver, so a piece of Element Call can ask for no more than
|
||||||
|
* it needs.
|
||||||
|
*/
|
||||||
|
export interface ElementCallMatrixClientDriver
|
||||||
|
extends RoomDriver, TimelineDriver, ProfileDriver, MediaDriver {
|
||||||
|
/** Who we publish as. */
|
||||||
|
readonly userId: string;
|
||||||
|
readonly deviceId: string;
|
||||||
|
/** The room this driver is bound to. */
|
||||||
|
readonly roomId: string;
|
||||||
|
getCapabilities(): Promise<DriverCapabilities>;
|
||||||
|
/** Free-form facts for a rageshake: crypto version, sync state, and so on. */
|
||||||
|
getDiagnostics?(): Promise<Record<string, string>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { MockElementCallMatrixClientDriver } from "./MockElementCallMatrixClientDriver";
|
||||||
|
import { MOCK_ROOM_ID } from "./MockRtcMatrixDriver";
|
||||||
|
|
||||||
|
describe("MockElementCallMatrixClientDriver", () => {
|
||||||
|
it("notifies room info, member and profile subscribers until unsubscribed", () => {
|
||||||
|
const driver = new MockElementCallMatrixClientDriver({
|
||||||
|
roomInfo: { name: "Standup" },
|
||||||
|
});
|
||||||
|
expect(driver.getRoomInfo().name).toBe("Standup");
|
||||||
|
const onInfo = vi.fn();
|
||||||
|
const off = driver.subscribeRoomInfo(onInfo);
|
||||||
|
driver.setRoomInfo({ name: "Retro" });
|
||||||
|
expect(onInfo).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: "Retro" }),
|
||||||
|
);
|
||||||
|
off();
|
||||||
|
driver.setRoomInfo({ name: "Planning" });
|
||||||
|
expect(onInfo).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const onMembers = vi.fn();
|
||||||
|
driver.subscribeRoomMembers(onMembers);
|
||||||
|
const alice = {
|
||||||
|
userId: "@alice:example.org",
|
||||||
|
displayName: "Alice",
|
||||||
|
avatarUrl: null,
|
||||||
|
membership: "join" as const,
|
||||||
|
};
|
||||||
|
driver.setRoomMembers([alice]);
|
||||||
|
expect(onMembers).toHaveBeenCalledWith([alice]);
|
||||||
|
expect(driver.getRoomMembers()).toEqual([alice]);
|
||||||
|
|
||||||
|
const onProfile = vi.fn();
|
||||||
|
driver.subscribeOwnProfile(onProfile);
|
||||||
|
driver.setOwnProfile({ displayName: "Bob" });
|
||||||
|
expect(onProfile).toHaveBeenCalledWith({
|
||||||
|
displayName: "Bob",
|
||||||
|
avatarUrl: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records sent room events, echoes them, and resolves relations", async () => {
|
||||||
|
const driver = new MockElementCallMatrixClientDriver();
|
||||||
|
const seen: string[] = [];
|
||||||
|
driver.subscribeTimeline((e) => seen.push(e.type));
|
||||||
|
const { eventId } = await driver.sendRoomEvent("m.reaction", {
|
||||||
|
"m.relates_to": {
|
||||||
|
rel_type: "m.annotation",
|
||||||
|
event_id: "$membership",
|
||||||
|
key: "🖐️",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(driver.calls("sendRoomEvent")[0]).toMatchObject({
|
||||||
|
eventType: "m.reaction",
|
||||||
|
eventId,
|
||||||
|
});
|
||||||
|
expect(seen).toEqual(["m.reaction"]);
|
||||||
|
expect(
|
||||||
|
driver.getRelatedEvents("$membership", "m.annotation", "m.reaction"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
await driver.redactEvent(eventId);
|
||||||
|
expect(seen).toEqual(["m.reaction", "m.room.redaction"]);
|
||||||
|
expect(
|
||||||
|
driver.getRelatedEvents("$membership", "m.annotation", "m.reaction"),
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers thumbnails for mxc urls only", async () => {
|
||||||
|
const driver = new MockElementCallMatrixClientDriver();
|
||||||
|
await expect(
|
||||||
|
driver.thumbnailUrl("mxc://example.org/abc", 96, 96, "crop"),
|
||||||
|
).resolves.toContain("abc");
|
||||||
|
await expect(
|
||||||
|
driver.thumbnailUrl("https://not-mxc", 96, 96, "crop"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
expect(driver.roomId).toBe(MOCK_ROOM_ID);
|
||||||
|
await expect(driver.getCapabilities()).resolves.toMatchObject({
|
||||||
|
stickyEvents: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An {@link ElementCallMatrixClientDriver} whose room lives in memory, for
|
||||||
|
* tests and stories: room info, members, a timeline and a profile, with
|
||||||
|
* setters that notify subscribers, and a record of what was sent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DriverCapabilities,
|
||||||
|
type ElementCallMatrixClientDriver,
|
||||||
|
type OwnProfile,
|
||||||
|
type RoomInfo,
|
||||||
|
type RoomMemberProfile,
|
||||||
|
type TimelineEvent,
|
||||||
|
type Unsubscribe,
|
||||||
|
} from "./ElementCallMatrixClientDriver";
|
||||||
|
import {
|
||||||
|
MOCK_OWN_DEVICE_ID,
|
||||||
|
MOCK_OWN_USER_ID,
|
||||||
|
MOCK_ROOM_ID,
|
||||||
|
} from "./MockRtcMatrixDriver";
|
||||||
|
|
||||||
|
export type ClientCall =
|
||||||
|
| {
|
||||||
|
kind: "sendRoomEvent";
|
||||||
|
eventType: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
eventId: string;
|
||||||
|
}
|
||||||
|
| { kind: "redactEvent"; eventId: string };
|
||||||
|
|
||||||
|
export interface MockElementCallMatrixClientDriverOptions {
|
||||||
|
userId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
roomId?: string;
|
||||||
|
roomInfo?: Partial<RoomInfo>;
|
||||||
|
members?: RoomMemberProfile[];
|
||||||
|
ownProfile?: Partial<OwnProfile>;
|
||||||
|
capabilities?: Partial<DriverCapabilities>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockElementCallMatrixClientDriver implements ElementCallMatrixClientDriver {
|
||||||
|
public readonly userId: string;
|
||||||
|
public readonly deviceId: string;
|
||||||
|
public readonly roomId: string;
|
||||||
|
public readonly outbound: ClientCall[] = [];
|
||||||
|
|
||||||
|
private capabilities: DriverCapabilities;
|
||||||
|
private roomInfo: RoomInfo;
|
||||||
|
private members: RoomMemberProfile[];
|
||||||
|
private ownProfile: OwnProfile;
|
||||||
|
private readonly timeline: TimelineEvent[] = [];
|
||||||
|
|
||||||
|
private readonly roomInfoListeners = new Set<(info: RoomInfo) => void>();
|
||||||
|
private readonly memberListeners = new Set<
|
||||||
|
(members: RoomMemberProfile[]) => void
|
||||||
|
>();
|
||||||
|
private readonly timelineListeners = new Set<
|
||||||
|
(event: TimelineEvent) => void
|
||||||
|
>();
|
||||||
|
private readonly profileListeners = new Set<(profile: OwnProfile) => void>();
|
||||||
|
private nextEventId = 0;
|
||||||
|
|
||||||
|
public constructor(options: MockElementCallMatrixClientDriverOptions = {}) {
|
||||||
|
this.userId = options.userId ?? MOCK_OWN_USER_ID;
|
||||||
|
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
|
||||||
|
this.roomId = options.roomId ?? MOCK_ROOM_ID;
|
||||||
|
this.capabilities = {
|
||||||
|
stickyEvents: true,
|
||||||
|
verifiedEventOrigins: true,
|
||||||
|
crossSigningVerdicts: true,
|
||||||
|
...options.capabilities,
|
||||||
|
};
|
||||||
|
this.roomInfo = {
|
||||||
|
name: "Test room",
|
||||||
|
canonicalAlias: null,
|
||||||
|
avatarUrl: null,
|
||||||
|
joinRule: "public",
|
||||||
|
encrypted: false,
|
||||||
|
canOpenSlot: true,
|
||||||
|
...options.roomInfo,
|
||||||
|
};
|
||||||
|
this.members = options.members ?? [];
|
||||||
|
this.ownProfile = {
|
||||||
|
displayName: "Me",
|
||||||
|
avatarUrl: null,
|
||||||
|
...options.ownProfile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public calls<K extends ClientCall["kind"]>(
|
||||||
|
kind: K,
|
||||||
|
): Extract<ClientCall, { kind: K }>[] {
|
||||||
|
return this.outbound.filter((c) => c.kind === kind) as Extract<
|
||||||
|
ClientCall,
|
||||||
|
{ kind: K }
|
||||||
|
>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- room ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public getRoomInfo(): RoomInfo {
|
||||||
|
return this.roomInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe {
|
||||||
|
return listen(this.roomInfoListeners, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setRoomInfo(info: Partial<RoomInfo>): void {
|
||||||
|
this.roomInfo = { ...this.roomInfo, ...info };
|
||||||
|
for (const l of this.roomInfoListeners) l(this.roomInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRoomMembers(): RoomMemberProfile[] {
|
||||||
|
return this.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeRoomMembers(
|
||||||
|
listener: (members: RoomMemberProfile[]) => void,
|
||||||
|
): Unsubscribe {
|
||||||
|
return listen(this.memberListeners, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setRoomMembers(members: RoomMemberProfile[]): void {
|
||||||
|
this.members = members;
|
||||||
|
for (const l of this.memberListeners) l(members);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- timeline --------------------------------------------------------------
|
||||||
|
|
||||||
|
public async sendRoomEvent(
|
||||||
|
eventType: string,
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
): Promise<{ eventId: string }> {
|
||||||
|
const eventId = `$echo-${this.nextEventId++}`;
|
||||||
|
this.outbound.push({ kind: "sendRoomEvent", eventType, content, eventId });
|
||||||
|
// Like sync, the room sees our event once the server has it.
|
||||||
|
this.emitTimelineEvent({
|
||||||
|
eventId,
|
||||||
|
type: eventType,
|
||||||
|
sender: this.userId,
|
||||||
|
content,
|
||||||
|
originServerTs: Date.now(),
|
||||||
|
});
|
||||||
|
return Promise.resolve({ eventId });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async redactEvent(eventId: string): Promise<void> {
|
||||||
|
this.outbound.push({ kind: "redactEvent", eventId });
|
||||||
|
this.emitTimelineEvent({
|
||||||
|
eventId: `$echo-${this.nextEventId++}`,
|
||||||
|
type: "m.room.redaction",
|
||||||
|
sender: this.userId,
|
||||||
|
content: { redacts: eventId },
|
||||||
|
originServerTs: Date.now(),
|
||||||
|
redacts: eventId,
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeTimeline(
|
||||||
|
listener: (event: TimelineEvent) => void,
|
||||||
|
): Unsubscribe {
|
||||||
|
return listen(this.timelineListeners, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A room event arrives from another user (or is echoed back). */
|
||||||
|
public emitTimelineEvent(event: TimelineEvent): void {
|
||||||
|
this.timeline.push(event);
|
||||||
|
for (const l of this.timelineListeners) l(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRelatedEvents(
|
||||||
|
eventId: string,
|
||||||
|
relType: string,
|
||||||
|
eventType: string,
|
||||||
|
): TimelineEvent[] {
|
||||||
|
const redacted = new Set(
|
||||||
|
this.timeline.flatMap((e) => (e.redacts ? [e.redacts] : [])),
|
||||||
|
);
|
||||||
|
return this.timeline.filter((e) => {
|
||||||
|
if (e.type !== eventType || redacted.has(e.eventId)) return false;
|
||||||
|
const relation = e.content["m.relates_to"] as
|
||||||
|
| { rel_type?: string; event_id?: string }
|
||||||
|
| undefined;
|
||||||
|
return relation?.rel_type === relType && relation.event_id === eventId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- profile, media, capabilities --------------------------------------------
|
||||||
|
|
||||||
|
public getOwnProfile(): OwnProfile {
|
||||||
|
return this.ownProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeOwnProfile(
|
||||||
|
listener: (profile: OwnProfile) => void,
|
||||||
|
): Unsubscribe {
|
||||||
|
return listen(this.profileListeners, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setOwnProfile(profile: Partial<OwnProfile>): void {
|
||||||
|
this.ownProfile = { ...this.ownProfile, ...profile };
|
||||||
|
for (const l of this.profileListeners) l(this.ownProfile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setDisplayName(name: string): Promise<void> {
|
||||||
|
this.setOwnProfile({ displayName: name });
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async thumbnailUrl(
|
||||||
|
mxcUrl: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
_resizeMethod: "crop" | "scale",
|
||||||
|
): Promise<string | null> {
|
||||||
|
return Promise.resolve(
|
||||||
|
mxcUrl.startsWith("mxc://")
|
||||||
|
? `https://media.example.org/thumbnail/${mxcUrl.slice("mxc://".length)}?width=${width}&height=${height}`
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCapabilities(): Promise<DriverCapabilities> {
|
||||||
|
return Promise.resolve(this.capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setCapabilities(capabilities: Partial<DriverCapabilities>): void {
|
||||||
|
this.capabilities = { ...this.capabilities, ...capabilities };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen<T>(listeners: Set<T>, listener: T): Unsubscribe {
|
||||||
|
listeners.add(listener);
|
||||||
|
return (): void => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
|
||||||
|
import {
|
||||||
|
FfiElementCallCompat,
|
||||||
|
FfiMatrixDriver,
|
||||||
|
FfiMembershipState,
|
||||||
|
FfiParticipationManager,
|
||||||
|
FfiStatus,
|
||||||
|
FfiTransportIntent,
|
||||||
|
type FfiParticipationConfig,
|
||||||
|
} from "../matrix-rtc-sdk";
|
||||||
|
import {
|
||||||
|
MOCK_LK_SERVICE_URL,
|
||||||
|
MOCK_OWN_USER_ID,
|
||||||
|
MOCK_SLOT_ID,
|
||||||
|
MockRtcMatrixDriver,
|
||||||
|
roomEncryptionEvent,
|
||||||
|
slotEvent,
|
||||||
|
waitFor,
|
||||||
|
} from "./MockRtcMatrixDriver";
|
||||||
|
|
||||||
|
const config: FfiParticipationConfig = {
|
||||||
|
compat: FfiElementCallCompat.StickyEvents,
|
||||||
|
manageMediaKeys: true,
|
||||||
|
requireCrossSignedSender: false,
|
||||||
|
useKeyDelayMs: 50n,
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinParams = {
|
||||||
|
applicationType: "m.call",
|
||||||
|
intent: undefined,
|
||||||
|
stickyDurationMs: 240_000n,
|
||||||
|
keepAliveTimeoutMs: 15_000n,
|
||||||
|
degradedLifetimeMs: undefined,
|
||||||
|
delegateDelayedLeave: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const publish = (): FfiTransportIntent =>
|
||||||
|
new FfiTransportIntent.Publish({
|
||||||
|
transport: {
|
||||||
|
transportType: "livekit",
|
||||||
|
propertiesJson: JSON.stringify({
|
||||||
|
livekit_service_url: MOCK_LK_SERVICE_URL,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function newManager(driver: MockRtcMatrixDriver): FfiParticipationManager {
|
||||||
|
return new FfiParticipationManager(
|
||||||
|
driver.roomId,
|
||||||
|
MOCK_SLOT_ID,
|
||||||
|
driver.userId,
|
||||||
|
driver.deviceId,
|
||||||
|
new FfiMatrixDriver(driver),
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MockRtcMatrixDriver as the crate's driver", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initMatrixRtcSdkForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("echoes our sticky join so our own membership reaches the roster", async () => {
|
||||||
|
const driver = new MockRtcMatrixDriver({
|
||||||
|
roomState: [slotEvent({ status: "open" })],
|
||||||
|
});
|
||||||
|
const manager = newManager(driver);
|
||||||
|
await manager.join(publish(), joinParams);
|
||||||
|
expect(FfiStatus.Connected.instanceOf(manager.status())).toBe(true);
|
||||||
|
const sticky = driver.calls("stickyEvent");
|
||||||
|
expect(sticky).toHaveLength(1);
|
||||||
|
expect(sticky[0].eventType).toBe("org.matrix.msc4143.rtc.member");
|
||||||
|
const me = manager
|
||||||
|
.memberships()
|
||||||
|
.find((m) => m.member.userId === MOCK_OWN_USER_ID);
|
||||||
|
expect(me?.state).toBe(FfiMembershipState.Joined);
|
||||||
|
expect(me?.connections).toEqual([MOCK_LK_SERVICE_URL]);
|
||||||
|
expect(me?.transportIdentity).toBe(manager.ownTransportIdentity());
|
||||||
|
// the token the mock minted is what the crate hands out
|
||||||
|
expect(manager.connections()[0].connection.jwtToken).toBe(
|
||||||
|
`jwt-for-${MOCK_LK_SERVICE_URL}`,
|
||||||
|
);
|
||||||
|
await manager.leave(undefined, undefined);
|
||||||
|
manager.uniffiDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hosts peers that join, answer our key and leave", async () => {
|
||||||
|
const driver = new MockRtcMatrixDriver({
|
||||||
|
roomState: [
|
||||||
|
roomEncryptionEvent(),
|
||||||
|
slotEvent({ status: "open", encrypted: true }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const manager = newManager(driver);
|
||||||
|
const peer = driver.addPeer({
|
||||||
|
userId: "@peer:example.org",
|
||||||
|
deviceId: "PEERDEV",
|
||||||
|
memberId: "m-peer",
|
||||||
|
});
|
||||||
|
await manager.join(
|
||||||
|
new FfiTransportIntent.ReceiveOnly({ canSubscribe: ["livekit"] }),
|
||||||
|
joinParams,
|
||||||
|
);
|
||||||
|
driver.peerJoins(peer);
|
||||||
|
expect(manager.memberships().map((m) => m.member.userId)).toContain(
|
||||||
|
peer.userId,
|
||||||
|
);
|
||||||
|
await waitFor("key exchange", () =>
|
||||||
|
manager.keyMap().some((k) => k.memberId === peer.memberId),
|
||||||
|
);
|
||||||
|
// StickyEvents compat: our key went out in the deployed dialect
|
||||||
|
expect(driver.calls("toDevice")[0].eventType).toBe(
|
||||||
|
"io.element.call.encryption_keys",
|
||||||
|
);
|
||||||
|
driver.peerLeaves(peer);
|
||||||
|
expect(
|
||||||
|
manager.memberships().find((m) => m.member.memberId === peer.memberId)
|
||||||
|
?.state,
|
||||||
|
).toBe(FfiMembershipState.LeftWithKeys);
|
||||||
|
await manager.leave(undefined, undefined);
|
||||||
|
manager.uniffiDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports homeserver connectivity into the crate", async () => {
|
||||||
|
const driver = new MockRtcMatrixDriver({
|
||||||
|
roomState: [slotEvent({ status: "open" })],
|
||||||
|
});
|
||||||
|
const manager = newManager(driver);
|
||||||
|
expect(manager.isHomeserverConnected()).toBe(true);
|
||||||
|
await manager.join(publish(), joinParams);
|
||||||
|
driver.setHomeserverConnected(false);
|
||||||
|
await waitFor("outage reported", () => !manager.isHomeserverConnected());
|
||||||
|
await waitFor("impairment", () => {
|
||||||
|
const status = manager.status();
|
||||||
|
return (
|
||||||
|
FfiStatus.Connected.instanceOf(status) &&
|
||||||
|
status.inner.impairments[0]?.tag === "HomeserverUnreachable"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
driver.setHomeserverConnected(true);
|
||||||
|
await waitFor("outage clears", () => {
|
||||||
|
const status = manager.status();
|
||||||
|
return (
|
||||||
|
FfiStatus.Connected.instanceOf(status) &&
|
||||||
|
status.inner.impairments.length === 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await manager.leave(undefined, undefined);
|
||||||
|
manager.uniffiDestroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can refuse sticky and delayed events like an old homeserver", async () => {
|
||||||
|
const driver = new MockRtcMatrixDriver({
|
||||||
|
roomState: [slotEvent({ status: "open" })],
|
||||||
|
});
|
||||||
|
driver.refuseStickyEvents = true;
|
||||||
|
const manager = newManager(driver);
|
||||||
|
await expect(manager.join(publish(), joinParams)).rejects.toThrow();
|
||||||
|
expect(FfiStatus.Disconnected.instanceOf(manager.status())).toBe(true);
|
||||||
|
manager.uniffiDestroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An {@link RtcMatrixDriver} with a homeserver made of arrays, for tests and
|
||||||
|
* stories.
|
||||||
|
*
|
||||||
|
* It models what sync would do: it records every outbound call, echoes
|
||||||
|
* accepted sticky and state events back through the room-event sink (so our
|
||||||
|
* own membership reaches the roster like anybody else's), answers `readState`
|
||||||
|
* from `roomState`, mints tokens, reports homeserver connectivity, and hosts
|
||||||
|
* simulated peers that answer our media key with theirs.
|
||||||
|
*
|
||||||
|
* A port of the crate's `web-test-app/src/mockDriver.ts`, which is the
|
||||||
|
* source of truth for the fabricated wire shapes below.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
FfiEventOrigin,
|
||||||
|
RtcError,
|
||||||
|
type ConnectivitySinkLike,
|
||||||
|
type FfiLivekitToken,
|
||||||
|
type FfiLivekitTokenRequest,
|
||||||
|
type FfiRtcTransport,
|
||||||
|
type FfiSendEventResponse,
|
||||||
|
type FfiToDeviceDelivery,
|
||||||
|
type FfiToDeviceRecipient,
|
||||||
|
type RoomEventSinkLike,
|
||||||
|
type StateUpdateSinkLike,
|
||||||
|
type ToDeviceSinkLike,
|
||||||
|
} from "../matrix-rtc-sdk";
|
||||||
|
import { type RtcMatrixDriver } from "./RtcMatrixDriver";
|
||||||
|
|
||||||
|
export const MOCK_LK_SERVICE_URL = "https://lk.example.org";
|
||||||
|
export const MOCK_ROOM_ID = "!room:example.org";
|
||||||
|
/** MSC4143: a slot id is `{application_type}#{id}`; Element Call's is this. */
|
||||||
|
export const MOCK_SLOT_ID = "m.call#ROOM";
|
||||||
|
export const MOCK_OWN_USER_ID = "@me:example.org";
|
||||||
|
export const MOCK_OWN_DEVICE_ID = "MYDEV";
|
||||||
|
|
||||||
|
/** Every call the crate made on the driver, in order. */
|
||||||
|
export type OutboundCall =
|
||||||
|
| {
|
||||||
|
kind: "stickyEvent";
|
||||||
|
roomId: string;
|
||||||
|
eventType: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
durationMs: bigint;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "stateEvent";
|
||||||
|
roomId: string;
|
||||||
|
eventType: string;
|
||||||
|
stateKey: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "delayedEvent";
|
||||||
|
roomId: string;
|
||||||
|
eventType: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
delayMs: bigint;
|
||||||
|
stickyDurationMs: bigint | undefined;
|
||||||
|
delayId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "delayedStateEvent";
|
||||||
|
roomId: string;
|
||||||
|
eventType: string;
|
||||||
|
stateKey: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
delayMs: bigint;
|
||||||
|
delayId: string;
|
||||||
|
}
|
||||||
|
| { kind: "restartDelayed"; roomId: string; delayId: string }
|
||||||
|
| { kind: "cancelDelayed"; roomId: string; delayId: string }
|
||||||
|
| {
|
||||||
|
kind: "delegateDelayedLeave";
|
||||||
|
roomId: string;
|
||||||
|
slotId: string;
|
||||||
|
delayId: string;
|
||||||
|
livekitServiceUrl: string | undefined;
|
||||||
|
delayMs: bigint;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "toDevice";
|
||||||
|
recipients: FfiToDeviceRecipient[];
|
||||||
|
eventType: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| { kind: "getRtcTransports" }
|
||||||
|
| {
|
||||||
|
kind: "getLivekitToken";
|
||||||
|
url: string;
|
||||||
|
roomId: string;
|
||||||
|
slotId: string;
|
||||||
|
member: Record<string, unknown>;
|
||||||
|
legacySfuGet: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A simulated remote participant. */
|
||||||
|
export interface RemotePeer {
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
memberId: string;
|
||||||
|
/** 32 key bytes; defaults to a constant pattern. */
|
||||||
|
key?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A raw Matrix event as the crate reads it. */
|
||||||
|
export type RawEvent = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface MockRtcMatrixDriverOptions {
|
||||||
|
userId?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
roomId?: string;
|
||||||
|
/** Room state answered by `readState` (the crate's session seed). */
|
||||||
|
roomState?: RawEvent[];
|
||||||
|
/** Advertised by `getRtcTransports`; an empty list is "none". */
|
||||||
|
transports?: FfiRtcTransport[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockRtcMatrixDriver implements RtcMatrixDriver {
|
||||||
|
/** Who this driver publishes as — not part of the contract, handy in tests. */
|
||||||
|
public readonly userId: string;
|
||||||
|
public readonly deviceId: string;
|
||||||
|
public readonly roomId: string;
|
||||||
|
|
||||||
|
public readonly outbound: OutboundCall[] = [];
|
||||||
|
/** Refuse delayed events like a homeserver without MSC4140 (404). */
|
||||||
|
public refuseDelayedEvents = false;
|
||||||
|
/** Refuse sticky events like a homeserver without MSC4354 (404). */
|
||||||
|
public refuseStickyEvents = false;
|
||||||
|
/** Make `getRtcTransports` fail rather than answer. */
|
||||||
|
public failTransportDiscovery = false;
|
||||||
|
public roomState: RawEvent[];
|
||||||
|
public transports: FfiRtcTransport[];
|
||||||
|
/** Simulated peers answer our media key with theirs (index 0). */
|
||||||
|
public readonly peers: RemotePeer[] = [];
|
||||||
|
|
||||||
|
private roomEventSink?: RoomEventSinkLike;
|
||||||
|
private toDeviceSink?: ToDeviceSinkLike;
|
||||||
|
private stateUpdateSink?: StateUpdateSinkLike;
|
||||||
|
private connectivitySink?: ConnectivitySinkLike;
|
||||||
|
private homeserverConnected = true;
|
||||||
|
|
||||||
|
private nextDelayId = 0;
|
||||||
|
private nextEventId = 0;
|
||||||
|
|
||||||
|
public constructor(options: MockRtcMatrixDriverOptions = {}) {
|
||||||
|
this.userId = options.userId ?? MOCK_OWN_USER_ID;
|
||||||
|
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
|
||||||
|
this.roomId = options.roomId ?? MOCK_ROOM_ID;
|
||||||
|
this.roomState = options.roomState ?? [];
|
||||||
|
this.transports = options.transports ?? [
|
||||||
|
{
|
||||||
|
transportType: "livekit",
|
||||||
|
propertiesJson: JSON.stringify({
|
||||||
|
livekit_service_url: MOCK_LK_SERVICE_URL,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- assertions ----------------------------------------------------------
|
||||||
|
|
||||||
|
public calls<K extends OutboundCall["kind"]>(
|
||||||
|
kind: K,
|
||||||
|
): Extract<OutboundCall, { kind: K }>[] {
|
||||||
|
return this.outbound.filter((c) => c.kind === kind) as Extract<
|
||||||
|
OutboundCall,
|
||||||
|
{ kind: K }
|
||||||
|
>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- outbound --------------------------------------------------------------
|
||||||
|
|
||||||
|
public async sendStickyEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
contentJson: string,
|
||||||
|
durationMs: bigint,
|
||||||
|
): Promise<FfiSendEventResponse> {
|
||||||
|
const content = parse(contentJson);
|
||||||
|
this.record({
|
||||||
|
kind: "stickyEvent",
|
||||||
|
roomId,
|
||||||
|
eventType,
|
||||||
|
content,
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
if (this.refuseStickyEvents)
|
||||||
|
throw new RtcError.Unsupported(
|
||||||
|
"M_UNRECOGNIZED: sticky events are not supported",
|
||||||
|
);
|
||||||
|
const eventId = this.eventId();
|
||||||
|
// The homeserver echoes our event through sync.
|
||||||
|
this.echo(
|
||||||
|
{
|
||||||
|
type: eventType,
|
||||||
|
sender: this.userId,
|
||||||
|
event_id: eventId,
|
||||||
|
room_id: roomId,
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
msc4354_sticky: { duration_ms: Number(durationMs) },
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
new FfiEventOrigin.Encrypted({ senderDeviceId: this.deviceId }),
|
||||||
|
);
|
||||||
|
return Promise.resolve({ eventId, delayId: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendStateEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string,
|
||||||
|
contentJson: string,
|
||||||
|
): Promise<FfiSendEventResponse> {
|
||||||
|
const content = parse(contentJson);
|
||||||
|
this.record({ kind: "stateEvent", roomId, eventType, stateKey, content });
|
||||||
|
const eventId = this.eventId();
|
||||||
|
this.echo(
|
||||||
|
{
|
||||||
|
type: eventType,
|
||||||
|
sender: this.userId,
|
||||||
|
event_id: eventId,
|
||||||
|
room_id: roomId,
|
||||||
|
state_key: stateKey,
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
new FfiEventOrigin.Cleartext(),
|
||||||
|
);
|
||||||
|
return Promise.resolve({ eventId, delayId: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelayedEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
contentJson: string,
|
||||||
|
delayMs: bigint,
|
||||||
|
stickyDurationMs: bigint | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
const delayId = `delay-${this.nextDelayId++}`;
|
||||||
|
this.record({
|
||||||
|
kind: "delayedEvent",
|
||||||
|
roomId,
|
||||||
|
eventType,
|
||||||
|
content: parse(contentJson),
|
||||||
|
delayMs,
|
||||||
|
stickyDurationMs,
|
||||||
|
delayId,
|
||||||
|
});
|
||||||
|
if (this.refuseDelayedEvents)
|
||||||
|
// 404 M_UNRECOGNIZED: "this homeserver will never do delayed events".
|
||||||
|
throw new RtcError.Unsupported(
|
||||||
|
"M_UNRECOGNIZED: delayed events are not supported",
|
||||||
|
);
|
||||||
|
return Promise.resolve(delayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelayedStateEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string,
|
||||||
|
contentJson: string,
|
||||||
|
delayMs: bigint,
|
||||||
|
): Promise<string> {
|
||||||
|
const delayId = `delay-${this.nextDelayId++}`;
|
||||||
|
this.record({
|
||||||
|
kind: "delayedStateEvent",
|
||||||
|
roomId,
|
||||||
|
eventType,
|
||||||
|
stateKey,
|
||||||
|
content: parse(contentJson),
|
||||||
|
delayMs,
|
||||||
|
delayId,
|
||||||
|
});
|
||||||
|
return Promise.resolve(delayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async restartDelayedEvent(
|
||||||
|
roomId: string,
|
||||||
|
delayId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
this.record({ kind: "restartDelayed", roomId, delayId });
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cancelDelayedEvent(
|
||||||
|
roomId: string,
|
||||||
|
delayId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
this.record({ kind: "cancelDelayed", roomId, delayId });
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delegateLivekitDelayedLeave(
|
||||||
|
roomId: string,
|
||||||
|
slotId: string,
|
||||||
|
_memberJson: string,
|
||||||
|
delayId: string,
|
||||||
|
livekitServiceUrl: string | undefined,
|
||||||
|
delayMs: bigint,
|
||||||
|
): Promise<void> {
|
||||||
|
this.record({
|
||||||
|
kind: "delegateDelayedLeave",
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
delayId,
|
||||||
|
livekitServiceUrl,
|
||||||
|
delayMs,
|
||||||
|
});
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendToDevice(
|
||||||
|
recipients: FfiToDeviceRecipient[],
|
||||||
|
eventType: string,
|
||||||
|
contentJson: string,
|
||||||
|
): Promise<FfiToDeviceDelivery[]> {
|
||||||
|
this.record({
|
||||||
|
kind: "toDevice",
|
||||||
|
recipients,
|
||||||
|
eventType,
|
||||||
|
content: parse(contentJson),
|
||||||
|
});
|
||||||
|
// Simulated peers answer with their own key.
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const peer = this.peers.find(
|
||||||
|
(p) =>
|
||||||
|
p.userId === recipient.userId && p.deviceId === recipient.deviceId,
|
||||||
|
);
|
||||||
|
if (peer) queueMicrotask(() => this.peerSendsKey(peer, 0));
|
||||||
|
}
|
||||||
|
// every recipient reachable
|
||||||
|
return Promise.resolve(
|
||||||
|
recipients.map((recipient) => ({ recipient, error: undefined })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
|
||||||
|
this.record({ kind: "getRtcTransports" });
|
||||||
|
if (this.failTransportDiscovery)
|
||||||
|
throw new RtcError.Http("500: transports endpoint unavailable");
|
||||||
|
return Promise.resolve(this.transports);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLivekitToken(
|
||||||
|
request: FfiLivekitTokenRequest,
|
||||||
|
): Promise<FfiLivekitToken> {
|
||||||
|
this.record({
|
||||||
|
kind: "getLivekitToken",
|
||||||
|
url: request.url,
|
||||||
|
roomId: request.roomId,
|
||||||
|
slotId: request.slotId,
|
||||||
|
member: parse(request.memberJson),
|
||||||
|
legacySfuGet: request.legacySfuGet,
|
||||||
|
});
|
||||||
|
return Promise.resolve({
|
||||||
|
jwt: "jwt-for-" + request.url,
|
||||||
|
url: request.url.replace("https", "wss"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readEvents(): Promise<string[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readState(
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string | undefined,
|
||||||
|
): Promise<string[]> {
|
||||||
|
return Promise.resolve(
|
||||||
|
this.roomState
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
e.type === eventType &&
|
||||||
|
(stateKey === undefined || e.state_key === stateKey),
|
||||||
|
)
|
||||||
|
.map((e) => JSON.stringify(e)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- inbound sinks -----------------------------------------------------------
|
||||||
|
// The crate subscribes exactly once, when its FfiMatrixDriver is built, and
|
||||||
|
// hands over sinks; a real driver hooks client listeners onto them. The
|
||||||
|
// mock stores them so tests can emit fabricated events.
|
||||||
|
|
||||||
|
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
|
||||||
|
this.roomEventSink = sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
|
||||||
|
this.toDeviceSink = sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
|
||||||
|
this.stateUpdateSink = sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
|
||||||
|
this.connectivitySink = sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isHomeserverConnected(): boolean {
|
||||||
|
return this.homeserverConnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The homeserver comes or goes, as a syncing client would report it. */
|
||||||
|
public setHomeserverConnected(connected: boolean): void {
|
||||||
|
this.homeserverConnected = connected;
|
||||||
|
this.connectivitySink?.emit(connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit any room event — sticky or state; the crate dispatches on type. */
|
||||||
|
public emitRoomEvent(event: RawEvent, origin: FfiEventOrigin): boolean {
|
||||||
|
if (!this.roomEventSink)
|
||||||
|
throw new Error("The SDK has not subscribed to room events");
|
||||||
|
return this.roomEventSink.emit(JSON.stringify(event), origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `senderCrossSigned` is the MSC4153 verdict; peers are cross-signed by default. */
|
||||||
|
public emitToDevice(
|
||||||
|
eventType: string,
|
||||||
|
sender: string,
|
||||||
|
content: RawEvent,
|
||||||
|
origin: FfiEventOrigin,
|
||||||
|
senderCrossSigned: boolean | undefined = true,
|
||||||
|
): boolean {
|
||||||
|
if (!this.toDeviceSink)
|
||||||
|
throw new Error("The SDK has not subscribed to to-device events");
|
||||||
|
return this.toDeviceSink.emit(
|
||||||
|
eventType,
|
||||||
|
sender,
|
||||||
|
JSON.stringify(content),
|
||||||
|
origin,
|
||||||
|
senderCrossSigned,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public emitStateUpdate(events: RawEvent[]): boolean {
|
||||||
|
if (!this.stateUpdateSink)
|
||||||
|
throw new Error("The SDK has not subscribed to state updates");
|
||||||
|
return this.stateUpdateSink.emit(events.map((e) => JSON.stringify(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- simulated peers --------------------------------------------------------
|
||||||
|
|
||||||
|
public addPeer(peer: RemotePeer): RemotePeer {
|
||||||
|
this.peers.push(peer);
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The peer publishes a join (on `MOCK_LK_SERVICE_URL` unless given). */
|
||||||
|
public peerJoins(
|
||||||
|
peer: RemotePeer,
|
||||||
|
opts: { lkServiceUrl?: string; durationMs?: number } = {},
|
||||||
|
): boolean {
|
||||||
|
return this.emitRoomEvent(
|
||||||
|
memberJoinEvent({
|
||||||
|
roomId: this.roomId,
|
||||||
|
userId: peer.userId,
|
||||||
|
memberId: peer.memberId,
|
||||||
|
...opts,
|
||||||
|
}),
|
||||||
|
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public peerLeaves(peer: RemotePeer): boolean {
|
||||||
|
return this.emitRoomEvent(
|
||||||
|
memberLeaveEvent({
|
||||||
|
roomId: this.roomId,
|
||||||
|
userId: peer.userId,
|
||||||
|
memberId: peer.memberId,
|
||||||
|
}),
|
||||||
|
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public peerSendsKey(peer: RemotePeer, index: number): boolean {
|
||||||
|
return this.emitToDevice(
|
||||||
|
"m.rtc.encryption_key",
|
||||||
|
peer.userId,
|
||||||
|
encryptionKeyContent({
|
||||||
|
roomId: this.roomId,
|
||||||
|
memberId: peer.memberId,
|
||||||
|
index,
|
||||||
|
key: peer.key,
|
||||||
|
}),
|
||||||
|
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals -----------------------------------------------------------------
|
||||||
|
|
||||||
|
private record(call: OutboundCall): void {
|
||||||
|
this.outbound.push(call);
|
||||||
|
}
|
||||||
|
|
||||||
|
private eventId(): string {
|
||||||
|
return `$echo-${this.nextEventId++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private echo(event: RawEvent, origin: FfiEventOrigin): void {
|
||||||
|
this.roomEventSink?.emit(JSON.stringify(event), origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(json: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(json) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inbound event fabrication — the MSC4143/MSC4354 wire shapes the crate's
|
||||||
|
// dispatch reads (see its src/session/dispatch.rs). Adjust here, not in
|
||||||
|
// every test.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let eventCounter = 0;
|
||||||
|
|
||||||
|
export function memberJoinEvent(opts: {
|
||||||
|
roomId?: string;
|
||||||
|
userId: string;
|
||||||
|
memberId: string;
|
||||||
|
lkServiceUrl?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}): RawEvent {
|
||||||
|
return {
|
||||||
|
type: "m.rtc.member",
|
||||||
|
sender: opts.userId,
|
||||||
|
event_id: `$ev-${eventCounter++}`,
|
||||||
|
room_id: opts.roomId ?? MOCK_ROOM_ID,
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
msc4354_sticky: { duration_ms: opts.durationMs ?? 240_000 },
|
||||||
|
content: {
|
||||||
|
slot_id: MOCK_SLOT_ID,
|
||||||
|
// MSC4354: the sticky key lives in the content and equals member.id.
|
||||||
|
msc4354_sticky_key: opts.memberId,
|
||||||
|
member: { id: opts.memberId, membership: "join" },
|
||||||
|
application: { type: "m.call" },
|
||||||
|
transports: {
|
||||||
|
published: [
|
||||||
|
{
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: opts.lkServiceUrl ?? MOCK_LK_SERVICE_URL,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
can_subscribe: ["livekit"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memberLeaveEvent(opts: {
|
||||||
|
roomId?: string;
|
||||||
|
userId: string;
|
||||||
|
memberId: string;
|
||||||
|
}): RawEvent {
|
||||||
|
return {
|
||||||
|
type: "m.rtc.member",
|
||||||
|
sender: opts.userId,
|
||||||
|
event_id: `$ev-${eventCounter++}`,
|
||||||
|
room_id: opts.roomId ?? MOCK_ROOM_ID,
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
msc4354_sticky: { duration_ms: 240_000 },
|
||||||
|
content: {
|
||||||
|
slot_id: MOCK_SLOT_ID,
|
||||||
|
msc4354_sticky_key: opts.memberId,
|
||||||
|
member: { id: opts.memberId, membership: "leave" },
|
||||||
|
leave_reason: { code: "leave" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slotEvent(
|
||||||
|
opts: { roomId?: string; status: "open" | "closed"; encrypted?: boolean } = {
|
||||||
|
status: "open",
|
||||||
|
},
|
||||||
|
): RawEvent {
|
||||||
|
const content: Record<string, unknown> = {
|
||||||
|
status: opts.status,
|
||||||
|
application: { type: "m.call" },
|
||||||
|
};
|
||||||
|
if (opts.encrypted) content.encryption = { type: "m.per_member" };
|
||||||
|
return {
|
||||||
|
type: "m.rtc.slot",
|
||||||
|
sender: "@admin:example.org",
|
||||||
|
event_id: `$ev-${eventCounter++}`,
|
||||||
|
room_id: opts.roomId ?? MOCK_ROOM_ID,
|
||||||
|
state_key: MOCK_SLOT_ID,
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roomEncryptionEvent(roomId = MOCK_ROOM_ID): RawEvent {
|
||||||
|
return {
|
||||||
|
type: "m.room.encryption",
|
||||||
|
sender: "@admin:example.org",
|
||||||
|
event_id: `$ev-${eventCounter++}`,
|
||||||
|
room_id: roomId,
|
||||||
|
state_key: "",
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `m.room.member` state with the profile fields the crate puts on a member. */
|
||||||
|
export function roomMemberEvent(opts: {
|
||||||
|
roomId?: string;
|
||||||
|
userId: string;
|
||||||
|
membership?: "join" | "invite" | "leave";
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}): RawEvent {
|
||||||
|
const content: Record<string, unknown> = {
|
||||||
|
membership: opts.membership ?? "join",
|
||||||
|
};
|
||||||
|
if (opts.displayName !== undefined) content.displayname = opts.displayName;
|
||||||
|
if (opts.avatarUrl !== undefined) content.avatar_url = opts.avatarUrl;
|
||||||
|
return {
|
||||||
|
type: "m.room.member",
|
||||||
|
sender: opts.userId,
|
||||||
|
event_id: `$ev-${eventCounter++}`,
|
||||||
|
room_id: opts.roomId ?? MOCK_ROOM_ID,
|
||||||
|
state_key: opts.userId,
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_KEY = new Uint8Array(32).fill(7);
|
||||||
|
|
||||||
|
function base64(bytes: Uint8Array): string {
|
||||||
|
let binary = "";
|
||||||
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary).replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MSC4143 `m.rtc.encryption_key` content. */
|
||||||
|
export function encryptionKeyContent(opts: {
|
||||||
|
roomId?: string;
|
||||||
|
memberId: string;
|
||||||
|
index: number;
|
||||||
|
key?: Uint8Array;
|
||||||
|
}): RawEvent {
|
||||||
|
return {
|
||||||
|
room_id: opts.roomId ?? MOCK_ROOM_ID,
|
||||||
|
member_id: opts.memberId,
|
||||||
|
media_key: { index: opts.index, key: base64(opts.key ?? DEFAULT_KEY) },
|
||||||
|
format: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One timer tick: the crate's listener callbacks arrive after the emitting task yields. */
|
||||||
|
export const tick = async (): Promise<void> =>
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
export async function waitFor(
|
||||||
|
what: string,
|
||||||
|
cond: () => boolean,
|
||||||
|
timeoutMs = 3000,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (!cond()) {
|
||||||
|
if (Date.now() > deadline)
|
||||||
|
throw new Error(`Timed out waiting for: ${what}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MatrixRTC half of what a host supplies: the `matrix-rtc` crate's own
|
||||||
|
* driver contract, taken verbatim from its bindings. Everything MatrixRTC —
|
||||||
|
* sticky and delayed events, to-device key delivery, transport tokens, the
|
||||||
|
* inbound event sinks and homeserver connectivity — goes through this and
|
||||||
|
* is consumed by the crate, never by Element Call directly.
|
||||||
|
*
|
||||||
|
* What a call needs from a Matrix client beyond MatrixRTC is the separate
|
||||||
|
* {@link ElementCallMatrixClientDriver}.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type MatrixDriverCallback } from "../matrix-rtc-sdk";
|
||||||
|
|
||||||
|
export type RtcMatrixDriver = MatrixDriverCallback;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MatrixEvent, RoomEvent, UserEvent } from "matrix-js-sdk";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { JsSdkElementCallMatrixClientDriver } from "./JsSdkElementCallMatrixClientDriver";
|
||||||
|
import {
|
||||||
|
ME,
|
||||||
|
ROOM_ID,
|
||||||
|
asClient,
|
||||||
|
asRoom,
|
||||||
|
fakeClient,
|
||||||
|
fakeRoom,
|
||||||
|
} from "./jsSdkTestFakes";
|
||||||
|
|
||||||
|
describe("JsSdkElementCallMatrixClientDriver", () => {
|
||||||
|
it("serves room info, members, timeline and profile from a full client", async () => {
|
||||||
|
const client = fakeClient(false);
|
||||||
|
const room = fakeRoom();
|
||||||
|
const driver = new JsSdkElementCallMatrixClientDriver(
|
||||||
|
asClient(client),
|
||||||
|
asRoom(room),
|
||||||
|
);
|
||||||
|
expect(driver.userId).toBe(ME);
|
||||||
|
expect(driver.getRoomInfo()).toEqual({
|
||||||
|
name: "Standup",
|
||||||
|
canonicalAlias: "#standup:example.org",
|
||||||
|
avatarUrl: "mxc://example.org/room",
|
||||||
|
joinRule: "public",
|
||||||
|
encrypted: true,
|
||||||
|
canOpenSlot: true,
|
||||||
|
});
|
||||||
|
const onInfo = vi.fn();
|
||||||
|
const offInfo = driver.subscribeRoomInfo(onInfo);
|
||||||
|
room.name = "Retro";
|
||||||
|
room.emit(RoomEvent.Name, room);
|
||||||
|
expect(onInfo).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: "Retro" }),
|
||||||
|
);
|
||||||
|
offInfo();
|
||||||
|
|
||||||
|
expect(driver.getRoomMembers()).toEqual([
|
||||||
|
{
|
||||||
|
userId: "@a:example.org",
|
||||||
|
displayName: "Alice",
|
||||||
|
avatarUrl: "mxc://example.org/alice",
|
||||||
|
membership: "join",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: "@b:example.org",
|
||||||
|
displayName: null,
|
||||||
|
avatarUrl: null,
|
||||||
|
membership: "invite",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const seen: string[] = [];
|
||||||
|
driver.subscribeTimeline((e) => seen.push(`${e.type}:${e.eventId}`));
|
||||||
|
const reaction = new MatrixEvent({
|
||||||
|
type: "m.reaction",
|
||||||
|
sender: "@a:example.org",
|
||||||
|
event_id: "$r1",
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
origin_server_ts: 3,
|
||||||
|
content: { "m.relates_to": { rel_type: "m.annotation", key: "🖐️" } },
|
||||||
|
});
|
||||||
|
client.emit(RoomEvent.Timeline, reaction, room, false, false, {});
|
||||||
|
client.emit(RoomEvent.Timeline, reaction, room, false, false, {});
|
||||||
|
await vi.waitFor(() => expect(seen).toEqual(["m.reaction:$r1"]));
|
||||||
|
|
||||||
|
await driver.sendRoomEvent("io.element.call.reaction", { emoji: "🎉" });
|
||||||
|
expect(client.sendEvent).toHaveBeenCalledWith(
|
||||||
|
ROOM_ID,
|
||||||
|
"io.element.call.reaction",
|
||||||
|
{ emoji: "🎉" },
|
||||||
|
);
|
||||||
|
await driver.redactEvent("$r1");
|
||||||
|
expect(client.redactEvent).toHaveBeenCalledWith(ROOM_ID, "$r1");
|
||||||
|
|
||||||
|
expect(driver.getOwnProfile()).toEqual({
|
||||||
|
displayName: "Me",
|
||||||
|
avatarUrl: "mxc://example.org/me",
|
||||||
|
});
|
||||||
|
const onProfile = vi.fn();
|
||||||
|
driver.subscribeOwnProfile(onProfile);
|
||||||
|
const user = client.getUser(ME)!;
|
||||||
|
user.rawDisplayName = "Moi";
|
||||||
|
user.emit(UserEvent.DisplayName, undefined, user);
|
||||||
|
expect(onProfile).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ displayName: "Moi" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(driver.getCapabilities()).resolves.toEqual({
|
||||||
|
stickyEvents: true,
|
||||||
|
verifiedEventOrigins: true,
|
||||||
|
crossSigningVerdicts: true,
|
||||||
|
});
|
||||||
|
await expect(driver.getDiagnostics()).resolves.toMatchObject({
|
||||||
|
matrix_backend: "jssdk",
|
||||||
|
crypto_version: "fake 1.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says what a widget client cannot vouch for and leaves media to the host bridge", async () => {
|
||||||
|
const driver = new JsSdkElementCallMatrixClientDriver(
|
||||||
|
asClient(fakeClient(true)),
|
||||||
|
asRoom(fakeRoom()),
|
||||||
|
);
|
||||||
|
await expect(driver.getCapabilities()).resolves.toMatchObject({
|
||||||
|
verifiedEventOrigins: false,
|
||||||
|
crossSigningVerdicts: false,
|
||||||
|
});
|
||||||
|
// no token of its own: media comes through the host bridge instead
|
||||||
|
await expect(
|
||||||
|
driver.thumbnailUrl("mxc://example.org/x", 96, 96, "crop"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An {@link ElementCallMatrixClientDriver} over a matrix-js-sdk client: room
|
||||||
|
* metadata and members, the room's timeline for reactions and notifications,
|
||||||
|
* the user's own profile, authenticated thumbnails and capability probes.
|
||||||
|
* Works on a full `MatrixClient` and on a `RoomWidgetClient`; the places
|
||||||
|
* they differ are marked "widget".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
KnownMembership,
|
||||||
|
type MatrixClient,
|
||||||
|
type MatrixEvent,
|
||||||
|
type Room,
|
||||||
|
RoomEvent,
|
||||||
|
type RoomMember,
|
||||||
|
RoomStateEvent,
|
||||||
|
RoomWidgetClient,
|
||||||
|
UNSTABLE_MSC4354_STICKY_EVENTS,
|
||||||
|
type User,
|
||||||
|
UserEvent,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
|
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { ELEMENT_CALL_SLOT_EVENT_TYPE } from "../../state/rtc/slot";
|
||||||
|
import {
|
||||||
|
type DriverCapabilities,
|
||||||
|
type ElementCallMatrixClientDriver,
|
||||||
|
type OwnProfile,
|
||||||
|
type RoomInfo,
|
||||||
|
type RoomMemberProfile,
|
||||||
|
type TimelineEvent,
|
||||||
|
type Unsubscribe,
|
||||||
|
} from "../ElementCallMatrixClientDriver";
|
||||||
|
|
||||||
|
/** The state event types `getRoomInfo()` is computed from. */
|
||||||
|
const ROOM_INFO_EVENT_TYPES = new Set([
|
||||||
|
"m.room.name",
|
||||||
|
"m.room.avatar",
|
||||||
|
"m.room.canonical_alias",
|
||||||
|
"m.room.join_rules",
|
||||||
|
"m.room.encryption",
|
||||||
|
"m.room.power_levels",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface JsSdkElementCallMatrixClientDriverOptions {
|
||||||
|
logger?: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClientDriver {
|
||||||
|
public readonly userId: string;
|
||||||
|
public readonly deviceId: string;
|
||||||
|
public readonly roomId: string;
|
||||||
|
|
||||||
|
private readonly logger: Logger;
|
||||||
|
/** Widget: no crypto backend, no access token, events without metadata. */
|
||||||
|
private readonly widget: boolean;
|
||||||
|
private capabilities: Promise<DriverCapabilities> | null = null;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly client: MatrixClient,
|
||||||
|
private readonly room: Room,
|
||||||
|
options: JsSdkElementCallMatrixClientDriverOptions = {},
|
||||||
|
) {
|
||||||
|
const userId = client.getUserId();
|
||||||
|
const deviceId = client.getDeviceId();
|
||||||
|
if (userId === null || deviceId === null)
|
||||||
|
throw new Error(
|
||||||
|
"The client must be logged in before it can drive a call",
|
||||||
|
);
|
||||||
|
this.userId = userId;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.roomId = room.roomId;
|
||||||
|
this.widget = client instanceof RoomWidgetClient;
|
||||||
|
this.logger = (options.logger ?? rootLogger).getChild(
|
||||||
|
`[JsSdkElementCallMatrixClientDriver ${room.roomId}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- room ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public getRoomInfo(): RoomInfo {
|
||||||
|
return {
|
||||||
|
name: this.room.name,
|
||||||
|
canonicalAlias: this.room.getCanonicalAlias(),
|
||||||
|
avatarUrl: this.room.getMxcAvatarUrl(),
|
||||||
|
joinRule: this.room.currentState.getJoinRule() ?? null,
|
||||||
|
encrypted: this.room.hasEncryptionStateEvent(),
|
||||||
|
canOpenSlot: this.room.currentState.maySendStateEvent(
|
||||||
|
ELEMENT_CALL_SLOT_EVENT_TYPE,
|
||||||
|
this.userId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe {
|
||||||
|
const notify = (): void => listener(this.getRoomInfo());
|
||||||
|
const onState = (event: MatrixEvent): void => {
|
||||||
|
if (
|
||||||
|
event.getRoomId() === this.roomId &&
|
||||||
|
ROOM_INFO_EVENT_TYPES.has(event.getType())
|
||||||
|
)
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
this.room.on(RoomEvent.Name, notify);
|
||||||
|
this.client.on(RoomStateEvent.Events, onState);
|
||||||
|
return (): void => {
|
||||||
|
this.room.off(RoomEvent.Name, notify);
|
||||||
|
this.client.off(RoomStateEvent.Events, onState);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRoomMembers(): RoomMemberProfile[] {
|
||||||
|
const profile =
|
||||||
|
(membership: "join" | "invite") =>
|
||||||
|
(member: RoomMember): RoomMemberProfile => ({
|
||||||
|
userId: member.userId,
|
||||||
|
displayName: member.rawDisplayName ?? null,
|
||||||
|
avatarUrl: member.getMxcAvatarUrl() ?? null,
|
||||||
|
membership,
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
...this.room
|
||||||
|
.getMembersWithMembership(KnownMembership.Join)
|
||||||
|
.map(profile("join")),
|
||||||
|
...this.room
|
||||||
|
.getMembersWithMembership(KnownMembership.Invite)
|
||||||
|
.map(profile("invite")),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeRoomMembers(
|
||||||
|
listener: (members: RoomMemberProfile[]) => void,
|
||||||
|
): Unsubscribe {
|
||||||
|
const onMembers = (event: MatrixEvent): void => {
|
||||||
|
if (event.getRoomId() === this.roomId) listener(this.getRoomMembers());
|
||||||
|
};
|
||||||
|
this.client.on(RoomStateEvent.Members, onMembers);
|
||||||
|
return (): void => {
|
||||||
|
this.client.off(RoomStateEvent.Members, onMembers);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- timeline ----------------------------------------------------------------
|
||||||
|
|
||||||
|
public async sendRoomEvent(
|
||||||
|
eventType: string,
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
): Promise<{ eventId: string }> {
|
||||||
|
const res = await this.client.sendEvent(
|
||||||
|
this.roomId,
|
||||||
|
eventType as never,
|
||||||
|
content as never,
|
||||||
|
);
|
||||||
|
return { eventId: res.event_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async redactEvent(eventId: string): Promise<void> {
|
||||||
|
await this.client.redactEvent(this.roomId, eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeTimeline(
|
||||||
|
listener: (event: TimelineEvent) => void,
|
||||||
|
): Unsubscribe {
|
||||||
|
// Our own events are seen twice — as the local echo, then as sent —
|
||||||
|
// and a redaction is both a timeline event and a Redaction signal.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deliver = async (event: MatrixEvent): Promise<void> => {
|
||||||
|
if (event.getRoomId() !== this.roomId) return;
|
||||||
|
// Still sending: the LocalEchoUpdated listener gets the real id later.
|
||||||
|
if (event.status !== null) return;
|
||||||
|
if (event.unstableStickyInfo !== undefined || event.isState()) return;
|
||||||
|
const eventId = event.getId();
|
||||||
|
const sender = event.getSender();
|
||||||
|
if (!eventId || !sender || seen.has(eventId)) return;
|
||||||
|
try {
|
||||||
|
await this.client.decryptEventIfNeeded(event);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Could not decrypt ${eventId}`, e);
|
||||||
|
}
|
||||||
|
if (event.isDecryptionFailure() || seen.has(eventId)) return;
|
||||||
|
seen.add(eventId);
|
||||||
|
if (seen.size > 1000) seen.delete(seen.values().next().value!);
|
||||||
|
listener({
|
||||||
|
eventId,
|
||||||
|
type: event.getType(),
|
||||||
|
sender,
|
||||||
|
content: event.getContent(),
|
||||||
|
originServerTs: event.getTs(),
|
||||||
|
redacts: event.event.redacts,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onTimeline = (
|
||||||
|
event: MatrixEvent,
|
||||||
|
room: Room | undefined,
|
||||||
|
toStartOfTimeline: boolean | undefined,
|
||||||
|
): void => {
|
||||||
|
if (room?.roomId === this.roomId && !toStartOfTimeline)
|
||||||
|
void deliver(event);
|
||||||
|
};
|
||||||
|
const onEcho = (event: MatrixEvent): void => void deliver(event);
|
||||||
|
this.client.on(RoomEvent.Timeline, onTimeline);
|
||||||
|
this.room.on(RoomEvent.LocalEchoUpdated, onEcho);
|
||||||
|
this.room.on(RoomEvent.Redaction, onEcho);
|
||||||
|
return (): void => {
|
||||||
|
this.client.off(RoomEvent.Timeline, onTimeline);
|
||||||
|
this.room.off(RoomEvent.LocalEchoUpdated, onEcho);
|
||||||
|
this.room.off(RoomEvent.Redaction, onEcho);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRelatedEvents(
|
||||||
|
eventId: string,
|
||||||
|
relType: string,
|
||||||
|
eventType: string,
|
||||||
|
): TimelineEvent[] {
|
||||||
|
const relations = this.room.relations.getChildEventsForEvent(
|
||||||
|
eventId,
|
||||||
|
relType as never,
|
||||||
|
eventType as never,
|
||||||
|
);
|
||||||
|
return (relations?.getRelations() ?? [])
|
||||||
|
.filter(
|
||||||
|
(event) => !event.isRedacted() && event.getId() && event.getSender(),
|
||||||
|
)
|
||||||
|
.map((event) => ({
|
||||||
|
eventId: event.getId()!,
|
||||||
|
type: event.getType(),
|
||||||
|
sender: event.getSender()!,
|
||||||
|
content: event.getContent(),
|
||||||
|
originServerTs: event.getTs(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- profile -------------------------------------------------------------------
|
||||||
|
|
||||||
|
public getOwnProfile(): OwnProfile {
|
||||||
|
const user = this.client.getUser(this.userId);
|
||||||
|
return {
|
||||||
|
displayName: user?.rawDisplayName ?? null,
|
||||||
|
avatarUrl: user?.avatarUrl ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeOwnProfile(
|
||||||
|
listener: (profile: OwnProfile) => void,
|
||||||
|
): Unsubscribe {
|
||||||
|
const user: User | null = this.client.getUser(this.userId);
|
||||||
|
if (user === null) return (): void => {};
|
||||||
|
const notify = (): void => listener(this.getOwnProfile());
|
||||||
|
user.on(UserEvent.DisplayName, notify);
|
||||||
|
user.on(UserEvent.AvatarUrl, notify);
|
||||||
|
return (): void => {
|
||||||
|
user.off(UserEvent.DisplayName, notify);
|
||||||
|
user.off(UserEvent.AvatarUrl, notify);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setDisplayName(name: string): Promise<void> {
|
||||||
|
await this.client.setDisplayName(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setAvatar(file: Blob): Promise<void> {
|
||||||
|
const { content_uri: uri } = await this.client.uploadContent(file);
|
||||||
|
await this.client.setAvatarUrl(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- media -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public async thumbnailUrl(
|
||||||
|
mxcUrl: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
resizeMethod: "crop" | "scale",
|
||||||
|
): Promise<string | null> {
|
||||||
|
const httpUrl = this.client.mxcUrlToHttp(
|
||||||
|
mxcUrl,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
resizeMethod,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// Widget: no token of our own; the host bridge downloads media instead.
|
||||||
|
const token = this.client.getAccessToken();
|
||||||
|
if (httpUrl === null || token === null) return null;
|
||||||
|
const response = await fetch(httpUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return URL.createObjectURL(await response.blob());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- capabilities and diagnostics ------------------------------------------------
|
||||||
|
|
||||||
|
public async getCapabilities(): Promise<DriverCapabilities> {
|
||||||
|
this.capabilities ??= this.probeCapabilities();
|
||||||
|
return this.capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async probeCapabilities(): Promise<DriverCapabilities> {
|
||||||
|
const stickyEvents = await this.client
|
||||||
|
.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS)
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
this.logger.warn("Could not probe sticky event support", e);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
stickyEvents,
|
||||||
|
verifiedEventOrigins: !this.widget,
|
||||||
|
crossSigningVerdicts: this.client.getCrypto() !== undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDiagnostics(): Promise<Record<string, string>> {
|
||||||
|
return Promise.resolve({
|
||||||
|
matrix_backend: this.widget ? "widget" : "jssdk",
|
||||||
|
crypto_version: this.client.getCrypto()?.getVersion() ?? "none",
|
||||||
|
sync_state: String(this.client.getSyncState()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
MatrixError,
|
||||||
|
MatrixEvent,
|
||||||
|
RoomStateEvent,
|
||||||
|
RoomStickyEventsEvent,
|
||||||
|
SyncState,
|
||||||
|
UnsupportedStickyEventsEndpointError,
|
||||||
|
UpdateDelayedEventAction,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
|
||||||
|
import {
|
||||||
|
FfiEventOrigin,
|
||||||
|
RtcError,
|
||||||
|
type ConnectivitySinkLike,
|
||||||
|
type RoomEventSinkLike,
|
||||||
|
type StateUpdateSinkLike,
|
||||||
|
type ToDeviceSinkLike,
|
||||||
|
} from "../../matrix-rtc-sdk";
|
||||||
|
import { JsSdkRtcMatrixDriver } from "./JsSdkRtcMatrixDriver";
|
||||||
|
import {
|
||||||
|
LK,
|
||||||
|
ME,
|
||||||
|
MY_DEVICE,
|
||||||
|
ROOM_ID,
|
||||||
|
asClient,
|
||||||
|
asRoom,
|
||||||
|
fakeClient,
|
||||||
|
fakeRoom,
|
||||||
|
jsonResponse,
|
||||||
|
openIdToken,
|
||||||
|
type FakeClient,
|
||||||
|
type FakeRoom,
|
||||||
|
} from "./jsSdkTestFakes";
|
||||||
|
|
||||||
|
const memberJson = JSON.stringify({
|
||||||
|
id: "m-1",
|
||||||
|
claimed_user_id: ME,
|
||||||
|
claimed_device_id: MY_DEVICE,
|
||||||
|
});
|
||||||
|
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||||
|
|
||||||
|
describe("JsSdkRtcMatrixDriver", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// The bindings define the RtcError classes the driver throws.
|
||||||
|
await initMatrixRtcSdkForTests();
|
||||||
|
fetchMock = vi.fn<typeof fetch>();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("over a full MatrixClient", () => {
|
||||||
|
it("sends sticky, delayed and state events through the unstable APIs", async () => {
|
||||||
|
const { client, driver } = fullClient();
|
||||||
|
await expect(
|
||||||
|
driver.sendStickyEvent(ROOM_ID, "m.rtc.member", '{"a":1}', 240_000n),
|
||||||
|
).resolves.toEqual({ eventId: "$sticky", delayId: undefined });
|
||||||
|
expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith(
|
||||||
|
ROOM_ID,
|
||||||
|
240_000,
|
||||||
|
null,
|
||||||
|
"m.rtc.member",
|
||||||
|
{ a: 1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
driver.sendDelayedEvent(
|
||||||
|
ROOM_ID,
|
||||||
|
"m.rtc.member",
|
||||||
|
"{}",
|
||||||
|
15_000n,
|
||||||
|
240_000n,
|
||||||
|
),
|
||||||
|
).resolves.toBe("delay-sticky");
|
||||||
|
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
|
||||||
|
ROOM_ID,
|
||||||
|
240_000,
|
||||||
|
{ delay: 15_000 },
|
||||||
|
null,
|
||||||
|
"m.rtc.member",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
driver.sendDelayedEvent(
|
||||||
|
ROOM_ID,
|
||||||
|
"m.rtc.member",
|
||||||
|
"{}",
|
||||||
|
15_000n,
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
).resolves.toBe("delay-plain");
|
||||||
|
expect(client._unstable_sendDelayedEvent).toHaveBeenCalledWith(
|
||||||
|
ROOM_ID,
|
||||||
|
{ delay: 15_000 },
|
||||||
|
null,
|
||||||
|
"m.rtc.member",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
await driver.sendDelayedStateEvent(ROOM_ID, "m.x", "key", "{}", 5_000n);
|
||||||
|
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
|
||||||
|
ROOM_ID,
|
||||||
|
{ delay: 5_000 },
|
||||||
|
"m.x",
|
||||||
|
{},
|
||||||
|
"key",
|
||||||
|
);
|
||||||
|
await driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "m.call#ROOM", "{}");
|
||||||
|
expect(client.sendStateEvent).toHaveBeenCalledWith(
|
||||||
|
ROOM_ID,
|
||||||
|
"m.rtc.slot",
|
||||||
|
{},
|
||||||
|
"m.call#ROOM",
|
||||||
|
);
|
||||||
|
|
||||||
|
await driver.restartDelayedEvent(ROOM_ID, "d1");
|
||||||
|
await driver.cancelDelayedEvent(ROOM_ID, "d1");
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"d1",
|
||||||
|
UpdateDelayedEventAction.Restart,
|
||||||
|
);
|
||||||
|
expect(client._unstable_updateDelayedEvent).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"d1",
|
||||||
|
UpdateDelayedEventAction.Cancel,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps js-sdk failures onto the crate's error family", async () => {
|
||||||
|
const { client, driver } = fullClient();
|
||||||
|
client._unstable_sendStickyEvent.mockRejectedValueOnce(
|
||||||
|
new UnsupportedStickyEventsEndpointError("nope", "sendStickyEvent"),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
driver.sendStickyEvent(ROOM_ID, "m.rtc.member", "{}", 1n),
|
||||||
|
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
|
||||||
|
|
||||||
|
client.sendStateEvent.mockRejectedValueOnce(
|
||||||
|
new MatrixError({ errcode: "M_FORBIDDEN" }, 403),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "k", "{}"),
|
||||||
|
).rejects.toSatisfy((e) => RtcError.Rejected.instanceOf(e));
|
||||||
|
|
||||||
|
client.sendStateEvent.mockRejectedValueOnce(
|
||||||
|
new MatrixError(
|
||||||
|
{ errcode: "M_LIMIT_EXCEEDED", retry_after_ms: 1500 },
|
||||||
|
429,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "k", "{}"),
|
||||||
|
).rejects.toSatisfy(
|
||||||
|
(e) =>
|
||||||
|
RtcError.RateLimited.instanceOf(e) && e.inner.retryAfterMs === 1500n,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends to-device messages Olm-encrypted per device", async () => {
|
||||||
|
const { client, driver } = fullClient();
|
||||||
|
const recipients = [{ userId: "@a:example.org", deviceId: "ADEV" }];
|
||||||
|
await expect(
|
||||||
|
driver.sendToDevice(recipients, "m.rtc.encryption_key", '{"k":1}'),
|
||||||
|
).resolves.toEqual([{ recipient: recipients[0], error: undefined }]);
|
||||||
|
expect(client.encryptAndSendToDevice).toHaveBeenCalledWith(
|
||||||
|
"m.rtc.encryption_key",
|
||||||
|
recipients,
|
||||||
|
{ k: 1 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discovers transports through the client and answers in the crate's shape", async () => {
|
||||||
|
const { client, driver } = fullClient();
|
||||||
|
client._unstable_getRTCTransports.mockResolvedValue([
|
||||||
|
{ type: "livekit", livekit_service_url: LK },
|
||||||
|
]);
|
||||||
|
await expect(driver.getRtcTransports()).resolves.toEqual([
|
||||||
|
{
|
||||||
|
transportType: "livekit",
|
||||||
|
propertiesJson: JSON.stringify({ livekit_service_url: LK }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exchanges an OpenID token for a LiveKit token, on either endpoint", async () => {
|
||||||
|
const { driver } = fullClient();
|
||||||
|
// a fresh Response per call: a body can be read once
|
||||||
|
fetchMock.mockImplementation(async () =>
|
||||||
|
Promise.resolve(jsonResponse({ jwt: "the-jwt", url: "wss://sfu" })),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
driver.getLivekitToken({
|
||||||
|
url: LK,
|
||||||
|
roomId: ROOM_ID,
|
||||||
|
slotId: "m.call#ROOM",
|
||||||
|
memberJson,
|
||||||
|
legacySfuGet: false,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ jwt: "the-jwt", url: "wss://sfu" });
|
||||||
|
const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(endpoint).toBe(`${LK}/get_token`);
|
||||||
|
expect(JSON.parse(init.body as string)).toEqual({
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
slot_id: "m.call#ROOM",
|
||||||
|
openid_token: openIdToken,
|
||||||
|
member: JSON.parse(memberJson),
|
||||||
|
});
|
||||||
|
|
||||||
|
await driver.getLivekitToken({
|
||||||
|
url: LK,
|
||||||
|
roomId: ROOM_ID,
|
||||||
|
slotId: "m.call#ROOM",
|
||||||
|
memberJson,
|
||||||
|
legacySfuGet: true,
|
||||||
|
});
|
||||||
|
const [legacyEndpoint, legacyInit] = fetchMock.mock
|
||||||
|
.calls[1] as unknown as [string, RequestInit];
|
||||||
|
expect(legacyEndpoint).toBe(`${LK}/sfu/get`);
|
||||||
|
expect(JSON.parse(legacyInit.body as string)).toEqual({
|
||||||
|
room: ROOM_ID,
|
||||||
|
openid_token: openIdToken,
|
||||||
|
device_id: MY_DEVICE,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response("gone", { status: 404 }));
|
||||||
|
await expect(
|
||||||
|
driver.getLivekitToken({
|
||||||
|
url: LK,
|
||||||
|
roomId: ROOM_ID,
|
||||||
|
slotId: "m.call#ROOM",
|
||||||
|
memberJson,
|
||||||
|
legacySfuGet: false,
|
||||||
|
}),
|
||||||
|
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates the delayed leave through the authorisation service's token endpoint", async () => {
|
||||||
|
const { driver } = fullClient();
|
||||||
|
fetchMock.mockImplementation(async () =>
|
||||||
|
Promise.resolve(jsonResponse({ jwt: "discarded" })),
|
||||||
|
);
|
||||||
|
await driver.delegateLivekitDelayedLeave(
|
||||||
|
ROOM_ID,
|
||||||
|
"m.call#ROOM",
|
||||||
|
memberJson,
|
||||||
|
"delay-1",
|
||||||
|
LK,
|
||||||
|
3_600_000n,
|
||||||
|
);
|
||||||
|
const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(endpoint).toBe(`${LK}/get_token`);
|
||||||
|
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||||
|
delay_id: "delay-1",
|
||||||
|
delay_timeout: 3_600_000,
|
||||||
|
delay_cs_api_url: "https://hs.example.org",
|
||||||
|
});
|
||||||
|
// receive-only: nothing to delegate to
|
||||||
|
await expect(
|
||||||
|
driver.delegateLivekitDelayedLeave(
|
||||||
|
ROOM_ID,
|
||||||
|
"m.call#ROOM",
|
||||||
|
memberJson,
|
||||||
|
"delay-1",
|
||||||
|
undefined,
|
||||||
|
1n,
|
||||||
|
),
|
||||||
|
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("feeds sticky events and state updates into the crate's sinks with their origin", async () => {
|
||||||
|
const { client, room, driver } = fullClient();
|
||||||
|
const roomSink = sink();
|
||||||
|
const stateSink = sink();
|
||||||
|
driver.subscribeRoomEvents(roomSink as unknown as RoomEventSinkLike);
|
||||||
|
driver.subscribeStateUpdates(stateSink as unknown as StateUpdateSinkLike);
|
||||||
|
|
||||||
|
const sticky = new MatrixEvent({
|
||||||
|
type: "m.rtc.member",
|
||||||
|
sender: "@a:example.org",
|
||||||
|
event_id: "$s1",
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
origin_server_ts: 1,
|
||||||
|
content: { slot_id: "m.call#ROOM" },
|
||||||
|
});
|
||||||
|
room.emit(RoomStickyEventsEvent.Update, [sticky], [], []);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(roomSink.emit).toHaveBeenCalledTimes(1);
|
||||||
|
const [json, origin] = roomSink.emit.mock.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
FfiEventOrigin,
|
||||||
|
];
|
||||||
|
expect(JSON.parse(json)).toMatchObject({
|
||||||
|
type: "m.rtc.member",
|
||||||
|
event_id: "$s1",
|
||||||
|
content: { slot_id: "m.call#ROOM" },
|
||||||
|
});
|
||||||
|
// not encrypted on this fake: the origin says so honestly
|
||||||
|
expect(FfiEventOrigin.Cleartext.instanceOf(origin)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const slot = new MatrixEvent({
|
||||||
|
type: "m.rtc.slot",
|
||||||
|
sender: "@admin:example.org",
|
||||||
|
event_id: "$slot",
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
state_key: "m.call#ROOM",
|
||||||
|
origin_server_ts: 2,
|
||||||
|
content: { status: "open" },
|
||||||
|
});
|
||||||
|
client.emit(RoomStateEvent.Events, slot, null, null);
|
||||||
|
expect(stateSink.emit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
JSON.parse(
|
||||||
|
(stateSink.emit.mock.calls[0] as unknown as [string[]])[0][0],
|
||||||
|
),
|
||||||
|
).toMatchObject({ type: "m.rtc.slot", state_key: "m.call#ROOM" });
|
||||||
|
|
||||||
|
// another room's state is not ours
|
||||||
|
client.emit(
|
||||||
|
RoomStateEvent.Events,
|
||||||
|
new MatrixEvent({ ...slot.event, room_id: "!other:example.org" }),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(stateSink.emit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports to-device messages with their Olm sender device and cross-signing verdict", async () => {
|
||||||
|
const { client, driver } = fullClient();
|
||||||
|
const toDevice = sink();
|
||||||
|
driver.subscribeToDeviceEvents(toDevice as unknown as ToDeviceSinkLike);
|
||||||
|
client.emit(ClientEvent.ReceivedToDeviceMessage, {
|
||||||
|
message: {
|
||||||
|
type: "m.rtc.encryption_key",
|
||||||
|
sender: "@a:example.org",
|
||||||
|
content: { member_id: "m-a" },
|
||||||
|
},
|
||||||
|
encryptionInfo: {
|
||||||
|
sender: "@a:example.org",
|
||||||
|
senderDevice: "ADEV",
|
||||||
|
senderCurve25519KeyBase64: "k",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(toDevice.emit).toHaveBeenCalledTimes(1));
|
||||||
|
const [type, sender, json, origin, crossSigned] = toDevice.emit.mock
|
||||||
|
.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
FfiEventOrigin,
|
||||||
|
boolean,
|
||||||
|
];
|
||||||
|
expect(type).toBe("m.rtc.encryption_key");
|
||||||
|
expect(sender).toBe("@a:example.org");
|
||||||
|
expect(JSON.parse(json)).toEqual({ member_id: "m-a" });
|
||||||
|
expect(senderDeviceOf(origin)).toBe("ADEV");
|
||||||
|
expect(crossSigned).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports homeserver connectivity from the sync state", () => {
|
||||||
|
const { client, driver } = fullClient();
|
||||||
|
expect(driver.isHomeserverConnected()).toBe(true);
|
||||||
|
const connectivity = sink();
|
||||||
|
driver.subscribeConnectivity(
|
||||||
|
connectivity as unknown as ConnectivitySinkLike,
|
||||||
|
);
|
||||||
|
client.getSyncState.mockReturnValue(SyncState.Error);
|
||||||
|
client.emit(ClientEvent.Sync, SyncState.Error, SyncState.Syncing);
|
||||||
|
expect(connectivity.emit).toHaveBeenCalledWith(false);
|
||||||
|
expect(driver.isHomeserverConnected()).toBe(false);
|
||||||
|
client.getSyncState.mockReturnValue(SyncState.Syncing);
|
||||||
|
client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Error);
|
||||||
|
expect(connectivity.emit).toHaveBeenLastCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("over a RoomWidgetClient", () => {
|
||||||
|
it("listens for the legacy to-device event and reports the claimed device", async () => {
|
||||||
|
const { client, driver } = widgetClient();
|
||||||
|
const toDevice = sink();
|
||||||
|
driver.subscribeToDeviceEvents(toDevice as unknown as ToDeviceSinkLike);
|
||||||
|
const event = new MatrixEvent({
|
||||||
|
type: "io.element.call.encryption_keys",
|
||||||
|
sender: "@a:example.org",
|
||||||
|
content: { member: { id: "m-a", claimed_device_id: "ADEV" } },
|
||||||
|
});
|
||||||
|
event.makeEncrypted("m.room.encrypted", {}, "", "");
|
||||||
|
client.emit(ClientEvent.ToDeviceEvent, event);
|
||||||
|
await vi.waitFor(() => expect(toDevice.emit).toHaveBeenCalledTimes(1));
|
||||||
|
const [, , , origin, crossSigned] = toDevice.emit.mock
|
||||||
|
.calls[0] as unknown as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
FfiEventOrigin,
|
||||||
|
boolean | undefined,
|
||||||
|
];
|
||||||
|
expect(senderDeviceOf(origin)).toBe("ADEV");
|
||||||
|
expect(crossSigned).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats member events in an encrypted room as encrypted by the claimed device", async () => {
|
||||||
|
const { room, driver } = widgetClient();
|
||||||
|
const roomSink = sink();
|
||||||
|
driver.subscribeRoomEvents(roomSink as unknown as RoomEventSinkLike);
|
||||||
|
room.emit(
|
||||||
|
RoomStickyEventsEvent.Update,
|
||||||
|
[
|
||||||
|
new MatrixEvent({
|
||||||
|
type: "m.rtc.member",
|
||||||
|
sender: "@a:example.org",
|
||||||
|
event_id: "$s1",
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
origin_server_ts: 1,
|
||||||
|
content: { slot_id: "m.call#ROOM", member: { device_id: "ADEV" } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(roomSink.emit).toHaveBeenCalledTimes(1));
|
||||||
|
const origin = (
|
||||||
|
roomSink.emit.mock.calls[0] as unknown as [string, FfiEventOrigin]
|
||||||
|
)[1];
|
||||||
|
expect(senderDeviceOf(origin)).toBe("ADEV");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function fullClient(): {
|
||||||
|
client: FakeClient;
|
||||||
|
room: FakeRoom;
|
||||||
|
driver: JsSdkRtcMatrixDriver;
|
||||||
|
} {
|
||||||
|
const client = fakeClient(false);
|
||||||
|
const room = fakeRoom();
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
room,
|
||||||
|
driver: new JsSdkRtcMatrixDriver(asClient(client), asRoom(room)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function widgetClient(): {
|
||||||
|
client: FakeClient;
|
||||||
|
room: FakeRoom;
|
||||||
|
driver: JsSdkRtcMatrixDriver;
|
||||||
|
} {
|
||||||
|
const client = fakeClient(true);
|
||||||
|
const room = fakeRoom();
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
room,
|
||||||
|
driver: new JsSdkRtcMatrixDriver(asClient(client), asRoom(room)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type SinkEmit = ReturnType<typeof vi.fn<(...args: unknown[]) => boolean>>;
|
||||||
|
|
||||||
|
function sink(): { emit: SinkEmit } {
|
||||||
|
return { emit: vi.fn<(...args: unknown[]) => boolean>(() => true) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sender device an origin carries, if it is an encrypted one. */
|
||||||
|
function senderDeviceOf(origin: FfiEventOrigin): string | undefined {
|
||||||
|
return FfiEventOrigin.Encrypted.instanceOf(origin)
|
||||||
|
? origin.inner.senderDeviceId
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An {@link RtcMatrixDriver} over a matrix-js-sdk client: the crate's
|
||||||
|
* MatrixRTC seam, nothing else. The standalone app, the widget and the SDK
|
||||||
|
* build hand this to Element Call, and a host that already runs matrix-js-sdk
|
||||||
|
* can use it as it is.
|
||||||
|
*
|
||||||
|
* It serves two kinds of client. A full `MatrixClient` syncs, decrypts and
|
||||||
|
* has an access token of its own. A `RoomWidgetClient` is a shell over the
|
||||||
|
* widget API: it never sees ciphertext (its host decrypts), has no crypto
|
||||||
|
* backend and no token, only emits the legacy to-device event, and answers
|
||||||
|
* transport discovery over the widget API. Every place the two differ is
|
||||||
|
* marked "widget".
|
||||||
|
*
|
||||||
|
* Adapted from the crate's `web-test-app/src/jsSdkDriver.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
type IOpenIDToken,
|
||||||
|
type MatrixClient,
|
||||||
|
type MatrixEvent,
|
||||||
|
MatrixError,
|
||||||
|
type ReceivedToDeviceMessage,
|
||||||
|
type Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomStateEvent,
|
||||||
|
RoomStickyEventsEvent,
|
||||||
|
RoomWidgetClient,
|
||||||
|
SyncState,
|
||||||
|
UnsupportedDelayedEventsEndpointError,
|
||||||
|
UnsupportedStickyEventsEndpointError,
|
||||||
|
UpdateDelayedEventAction,
|
||||||
|
parseErrorResponse,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
|
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import {
|
||||||
|
FfiEventOrigin,
|
||||||
|
RtcError,
|
||||||
|
type ConnectivitySinkLike,
|
||||||
|
type FfiLivekitToken,
|
||||||
|
type FfiLivekitTokenRequest,
|
||||||
|
type FfiRtcTransport,
|
||||||
|
type FfiSendEventResponse,
|
||||||
|
type FfiToDeviceDelivery,
|
||||||
|
type FfiToDeviceRecipient,
|
||||||
|
type RoomEventSinkLike,
|
||||||
|
type StateUpdateSinkLike,
|
||||||
|
type ToDeviceSinkLike,
|
||||||
|
} from "../../matrix-rtc-sdk";
|
||||||
|
import { doNetworkOperationWithRetry } from "../../utils/matrix";
|
||||||
|
import { type RtcMatrixDriver } from "../RtcMatrixDriver";
|
||||||
|
|
||||||
|
export interface JsSdkRtcMatrixDriverOptions {
|
||||||
|
logger?: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JsSdkRtcMatrixDriver implements RtcMatrixDriver {
|
||||||
|
private readonly roomId: string;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
/** Widget: the host decrypts for us and there is no crypto backend. */
|
||||||
|
private readonly widget: boolean;
|
||||||
|
private readonly detachers: (() => void)[] = [];
|
||||||
|
/** curve25519 sender key → device id, per megolm-attributed sender. */
|
||||||
|
private readonly senderDeviceCache = new Map<string, string>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly client: MatrixClient,
|
||||||
|
private readonly room: Room,
|
||||||
|
options: JsSdkRtcMatrixDriverOptions = {},
|
||||||
|
) {
|
||||||
|
this.roomId = room.roomId;
|
||||||
|
this.widget = client instanceof RoomWidgetClient;
|
||||||
|
this.logger = (options.logger ?? rootLogger).getChild(
|
||||||
|
`[JsSdkRtcMatrixDriver ${room.roomId}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unhooks every client listener. The crate's sinks stop being fed. */
|
||||||
|
public detach(): void {
|
||||||
|
for (const detach of this.detachers.splice(0)) detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- outbound --------------------------------------------------------------
|
||||||
|
|
||||||
|
public async sendStickyEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
contentJson: string,
|
||||||
|
durationMs: bigint,
|
||||||
|
): Promise<FfiSendEventResponse> {
|
||||||
|
return guard(async () => {
|
||||||
|
const res = await this.client._unstable_sendStickyEvent(
|
||||||
|
roomId,
|
||||||
|
Number(durationMs),
|
||||||
|
null,
|
||||||
|
eventType as never,
|
||||||
|
JSON.parse(contentJson) as never,
|
||||||
|
);
|
||||||
|
return { eventId: res.event_id, delayId: undefined };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendStateEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string,
|
||||||
|
contentJson: string,
|
||||||
|
): Promise<FfiSendEventResponse> {
|
||||||
|
return guard(async () => {
|
||||||
|
const res = await this.client.sendStateEvent(
|
||||||
|
roomId,
|
||||||
|
eventType as never,
|
||||||
|
JSON.parse(contentJson) as never,
|
||||||
|
stateKey,
|
||||||
|
);
|
||||||
|
return { eventId: res.event_id, delayId: undefined };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelayedEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
contentJson: string,
|
||||||
|
delayMs: bigint,
|
||||||
|
stickyDurationMs: bigint | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
return guard(async () => {
|
||||||
|
const content = JSON.parse(contentJson) as never;
|
||||||
|
const res =
|
||||||
|
stickyDurationMs === undefined
|
||||||
|
? await this.client._unstable_sendDelayedEvent(
|
||||||
|
roomId,
|
||||||
|
{ delay: Number(delayMs) },
|
||||||
|
null,
|
||||||
|
eventType as never,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
: await this.client._unstable_sendStickyDelayedEvent(
|
||||||
|
roomId,
|
||||||
|
Number(stickyDurationMs),
|
||||||
|
{ delay: Number(delayMs) },
|
||||||
|
null,
|
||||||
|
eventType as never,
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
return res.delay_id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendDelayedStateEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string,
|
||||||
|
contentJson: string,
|
||||||
|
delayMs: bigint,
|
||||||
|
): Promise<string> {
|
||||||
|
return guard(async () => {
|
||||||
|
const res = await this.client._unstable_sendDelayedStateEvent(
|
||||||
|
roomId,
|
||||||
|
{ delay: Number(delayMs) },
|
||||||
|
eventType as never,
|
||||||
|
JSON.parse(contentJson) as never,
|
||||||
|
stateKey,
|
||||||
|
);
|
||||||
|
return res.delay_id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async restartDelayedEvent(
|
||||||
|
_roomId: string,
|
||||||
|
delayId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await guard(async () =>
|
||||||
|
this.client._unstable_updateDelayedEvent(
|
||||||
|
delayId,
|
||||||
|
UpdateDelayedEventAction.Restart,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cancelDelayedEvent(
|
||||||
|
_roomId: string,
|
||||||
|
delayId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await guard(async () =>
|
||||||
|
this.client._unstable_updateDelayedEvent(
|
||||||
|
delayId,
|
||||||
|
UpdateDelayedEventAction.Cancel,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MSC4195, the way Element Call has always done it: the MatrixRTC
|
||||||
|
* authorisation service takes over the delayed leave when asked for a token
|
||||||
|
* with `delay_id`, `delay_timeout` and the homeserver it should restart it
|
||||||
|
* at. The token in the answer is discarded. (Interim: plan item C5 moves
|
||||||
|
* the choice of route into the crate and leaves only primitives here.)
|
||||||
|
*/
|
||||||
|
public async delegateLivekitDelayedLeave(
|
||||||
|
roomId: string,
|
||||||
|
slotId: string,
|
||||||
|
memberJson: string,
|
||||||
|
delayId: string,
|
||||||
|
livekitServiceUrl: string | undefined,
|
||||||
|
delayMs: bigint,
|
||||||
|
): Promise<void> {
|
||||||
|
if (livekitServiceUrl === undefined)
|
||||||
|
throw new RtcError.Unsupported(
|
||||||
|
"A receive-only member has no transport to delegate to",
|
||||||
|
);
|
||||||
|
await guard(async () => {
|
||||||
|
const member = JSON.parse(memberJson) as MemberClaims;
|
||||||
|
const delegation = {
|
||||||
|
delay_id: delayId,
|
||||||
|
delay_timeout: Number(delayMs),
|
||||||
|
delay_cs_api_url: this.client.baseUrl,
|
||||||
|
};
|
||||||
|
await this.requestToken(
|
||||||
|
livekitServiceUrl,
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
member,
|
||||||
|
false,
|
||||||
|
delegation,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendToDevice(
|
||||||
|
recipients: FfiToDeviceRecipient[],
|
||||||
|
eventType: string,
|
||||||
|
contentJson: string,
|
||||||
|
): Promise<FfiToDeviceDelivery[]> {
|
||||||
|
return guard(async () => {
|
||||||
|
// Olm-encrypted, per specific device — never `*`. On a widget client
|
||||||
|
// this asks the host to encrypt; the plain `sendToDevice` there would
|
||||||
|
// go out in clear.
|
||||||
|
await this.client.encryptAndSendToDevice(
|
||||||
|
eventType,
|
||||||
|
recipients,
|
||||||
|
JSON.parse(contentJson) as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
return recipients.map((recipient) => ({ recipient, error: undefined }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
|
||||||
|
return guard(async () => {
|
||||||
|
// The homeserver endpoint (MSC4143), or the widget host's answer to
|
||||||
|
// the same question (MSC4515) — the client knows which.
|
||||||
|
const transports = await doNetworkOperationWithRetry(async () =>
|
||||||
|
this.client._unstable_getRTCTransports(),
|
||||||
|
);
|
||||||
|
return transports.map(({ type, ...properties }: Transport) => ({
|
||||||
|
transportType: String(type),
|
||||||
|
propertiesJson: JSON.stringify(properties),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLivekitToken(
|
||||||
|
request: FfiLivekitTokenRequest,
|
||||||
|
): Promise<FfiLivekitToken> {
|
||||||
|
return guard(async () => {
|
||||||
|
const token = await this.requestToken(
|
||||||
|
request.url,
|
||||||
|
request.roomId,
|
||||||
|
request.slotId,
|
||||||
|
JSON.parse(request.memberJson) as MemberClaims,
|
||||||
|
request.legacySfuGet,
|
||||||
|
);
|
||||||
|
return { jwt: token.jwt, url: token.url };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestToken(
|
||||||
|
serviceUrl: string,
|
||||||
|
roomId: string,
|
||||||
|
slotId: string,
|
||||||
|
member: MemberClaims,
|
||||||
|
legacySfuGet: boolean,
|
||||||
|
delegation: Record<string, unknown> = {},
|
||||||
|
): Promise<{ jwt: string; url?: string }> {
|
||||||
|
let openIdToken: IOpenIDToken;
|
||||||
|
try {
|
||||||
|
openIdToken = await doNetworkOperationWithRetry(async () =>
|
||||||
|
this.client.getOpenIdToken(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw new RtcError.Http(`Could not get an OpenID token: ${String(e)}`);
|
||||||
|
}
|
||||||
|
const base = serviceUrl.replace(/\/$/, "");
|
||||||
|
const [endpoint, body] = legacySfuGet
|
||||||
|
? [
|
||||||
|
`${base}/sfu/get`,
|
||||||
|
{
|
||||||
|
// The legacy endpoint derives the LiveKit room alias from the
|
||||||
|
// Matrix room id alone.
|
||||||
|
room: roomId,
|
||||||
|
openid_token: openIdToken,
|
||||||
|
device_id: member.claimed_device_id,
|
||||||
|
...delegation,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
`${base}/get_token`,
|
||||||
|
{
|
||||||
|
room_id: roomId,
|
||||||
|
slot_id: slotId,
|
||||||
|
openid_token: openIdToken,
|
||||||
|
member,
|
||||||
|
...delegation,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const response = await doNetworkOperationWithRetry(async () =>
|
||||||
|
fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (response.status === 404)
|
||||||
|
throw new RtcError.Unsupported(`${endpoint}: ${response.status}`);
|
||||||
|
throw parseErrorResponse(response, text);
|
||||||
|
}
|
||||||
|
return (await response.json()) as { jwt: string; url?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reads (the crate's session seed) ---------------------------------------
|
||||||
|
|
||||||
|
public async readEvents(
|
||||||
|
eventType: string,
|
||||||
|
_stateKey: string | undefined,
|
||||||
|
limit: number,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const event of this.room._unstable_getStickyEvents()) {
|
||||||
|
await this.client.decryptEventIfNeeded(event);
|
||||||
|
if (event.getType() !== eventType) continue;
|
||||||
|
out.push(JSON.stringify(rawEvent(event)));
|
||||||
|
if (out.length >= limit) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async readState(
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string | undefined,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const events =
|
||||||
|
stateKey === undefined
|
||||||
|
? this.room.currentState.getStateEvents(eventType)
|
||||||
|
: [this.room.currentState.getStateEvents(eventType, stateKey)].filter(
|
||||||
|
(e): e is MatrixEvent => Boolean(e),
|
||||||
|
);
|
||||||
|
return Promise.resolve(
|
||||||
|
events.map((event) => JSON.stringify(rawEvent(event))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- inbound sinks ----------------------------------------------------------
|
||||||
|
|
||||||
|
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
|
||||||
|
const emit = async (event: MatrixEvent): Promise<void> => {
|
||||||
|
await this.client.decryptEventIfNeeded(event);
|
||||||
|
if (event.isDecryptionFailure()) {
|
||||||
|
this.logger.warn(`Event ${event.getId()} failed to decrypt; skipped`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!sink.emit(
|
||||||
|
JSON.stringify(rawEvent(event)),
|
||||||
|
await this.roomEventOrigin(event),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
detach();
|
||||||
|
};
|
||||||
|
// Sticky events come from the room's sticky store: sync delivers them in
|
||||||
|
// the room's `msc4354_sticky` section, our own included.
|
||||||
|
const onSticky = (
|
||||||
|
added: MatrixEvent[],
|
||||||
|
updated: { current: MatrixEvent }[],
|
||||||
|
): void => {
|
||||||
|
for (const event of [...added, ...updated.map((u) => u.current)])
|
||||||
|
void emit(event);
|
||||||
|
};
|
||||||
|
// Everything else (state events in the timeline) comes from the timeline;
|
||||||
|
// local echoes and sticky events are skipped there.
|
||||||
|
const onTimeline = (
|
||||||
|
event: MatrixEvent,
|
||||||
|
room: Room | undefined,
|
||||||
|
toStartOfTimeline: boolean | undefined,
|
||||||
|
): void => {
|
||||||
|
if (room?.roomId !== this.roomId || toStartOfTimeline) return;
|
||||||
|
if (event.status !== null || event.unstableStickyInfo !== undefined)
|
||||||
|
return;
|
||||||
|
void emit(event);
|
||||||
|
};
|
||||||
|
const detach = (): void => {
|
||||||
|
this.room.off(RoomStickyEventsEvent.Update, onSticky);
|
||||||
|
this.client.off(RoomEvent.Timeline, onTimeline);
|
||||||
|
};
|
||||||
|
this.room.on(RoomStickyEventsEvent.Update, onSticky);
|
||||||
|
this.client.on(RoomEvent.Timeline, onTimeline);
|
||||||
|
this.detachers.push(detach);
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
|
||||||
|
// Client-level listener: the room-level re-emit is unreliable under
|
||||||
|
// MSC4222 `state_after` churn.
|
||||||
|
const onState = (event: MatrixEvent): void => {
|
||||||
|
if (event.getRoomId() !== this.roomId) return;
|
||||||
|
if (!sink.emit([JSON.stringify(rawEvent(event))])) detach();
|
||||||
|
};
|
||||||
|
const detach = (): void => {
|
||||||
|
this.client.off(RoomStateEvent.Events, onState);
|
||||||
|
};
|
||||||
|
this.client.on(RoomStateEvent.Events, onState);
|
||||||
|
this.detachers.push(detach);
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
|
||||||
|
if (this.widget) {
|
||||||
|
// Widget: the legacy event is the only one the widget client emits.
|
||||||
|
// The host decrypted the message; it says whether it was encrypted but
|
||||||
|
// not by which device, so the device is the one the content claims.
|
||||||
|
const onToDevice = (event: MatrixEvent): void => {
|
||||||
|
const content = event.getContent() as KeyMessageContent;
|
||||||
|
const origin = event.isEncrypted()
|
||||||
|
? new FfiEventOrigin.Encrypted({
|
||||||
|
senderDeviceId: claimedKeyDevice(content),
|
||||||
|
})
|
||||||
|
: new FfiEventOrigin.Cleartext();
|
||||||
|
if (
|
||||||
|
!sink.emit(
|
||||||
|
event.getType(),
|
||||||
|
event.getSender() ?? "",
|
||||||
|
JSON.stringify(content),
|
||||||
|
origin,
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
detach();
|
||||||
|
};
|
||||||
|
const detach = (): void => {
|
||||||
|
this.client.off(ClientEvent.ToDeviceEvent, onToDevice);
|
||||||
|
};
|
||||||
|
this.client.on(ClientEvent.ToDeviceEvent, onToDevice);
|
||||||
|
this.detachers.push(detach);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onToDevice = (received: ReceivedToDeviceMessage): void =>
|
||||||
|
void handleToDevice(received);
|
||||||
|
const handleToDevice = async ({
|
||||||
|
message,
|
||||||
|
encryptionInfo,
|
||||||
|
}: ReceivedToDeviceMessage): Promise<void> => {
|
||||||
|
const origin = encryptionInfo
|
||||||
|
? new FfiEventOrigin.Encrypted({
|
||||||
|
senderDeviceId: encryptionInfo.senderDevice,
|
||||||
|
})
|
||||||
|
: new FfiEventOrigin.Cleartext();
|
||||||
|
// MSC4153: is the sending device cross-signed by its owner?
|
||||||
|
let crossSigned: boolean | undefined;
|
||||||
|
const crypto = this.client.getCrypto();
|
||||||
|
if (crypto && encryptionInfo?.senderDevice) {
|
||||||
|
const status = await crypto.getDeviceVerificationStatus(
|
||||||
|
encryptionInfo.sender,
|
||||||
|
encryptionInfo.senderDevice,
|
||||||
|
);
|
||||||
|
crossSigned = status?.signedByOwner ?? false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!sink.emit(
|
||||||
|
message.type,
|
||||||
|
message.sender,
|
||||||
|
JSON.stringify(message.content ?? {}),
|
||||||
|
origin,
|
||||||
|
crossSigned,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
detach();
|
||||||
|
};
|
||||||
|
const detach = (): void => {
|
||||||
|
this.client.off(ClientEvent.ReceivedToDeviceMessage, onToDevice);
|
||||||
|
};
|
||||||
|
this.client.on(ClientEvent.ReceivedToDeviceMessage, onToDevice);
|
||||||
|
this.detachers.push(detach);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- connectivity ------------------------------------------------------------
|
||||||
|
|
||||||
|
public isHomeserverConnected(): boolean {
|
||||||
|
// Widget: the widget client reports Syncing once it has seen an event
|
||||||
|
// and never anything else, so on a widget this is always true.
|
||||||
|
return this.client.getSyncState() === SyncState.Syncing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
|
||||||
|
const onSync = (): void => {
|
||||||
|
if (!sink.emit(this.isHomeserverConnected())) detach();
|
||||||
|
};
|
||||||
|
const detach = (): void => {
|
||||||
|
this.client.off(ClientEvent.Sync, onSync);
|
||||||
|
};
|
||||||
|
this.client.on(ClientEvent.Sync, onSync);
|
||||||
|
this.detachers.push(detach);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async roomEventOrigin(event: MatrixEvent): Promise<FfiEventOrigin> {
|
||||||
|
if (this.widget) {
|
||||||
|
// Widget: events arrive decrypted with no metadata. In an encrypted
|
||||||
|
// room they were encrypted, by the device the content claims — the
|
||||||
|
// same trust matrix-js-sdk's own session extends.
|
||||||
|
if (event.isState() || !this.room.hasEncryptionStateEvent())
|
||||||
|
return new FfiEventOrigin.Cleartext();
|
||||||
|
return new FfiEventOrigin.Encrypted({
|
||||||
|
senderDeviceId: claimedMemberDevice(event.getContent()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!event.isEncrypted()) return new FfiEventOrigin.Cleartext();
|
||||||
|
return new FfiEventOrigin.Encrypted({
|
||||||
|
senderDeviceId: await this.senderDeviceOf(event),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The device that megolm-encrypted `event` (sender key → device list). */
|
||||||
|
private async senderDeviceOf(
|
||||||
|
event: MatrixEvent,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const senderKey = event.getSenderKey();
|
||||||
|
const sender = event.getSender();
|
||||||
|
const crypto = this.client.getCrypto();
|
||||||
|
if (!senderKey || !sender || !crypto) return undefined;
|
||||||
|
const cached = this.senderDeviceCache.get(senderKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
const devices = await crypto.getUserDeviceInfo([sender], true);
|
||||||
|
for (const device of devices.get(sender)?.values() ?? []) {
|
||||||
|
if (device.getIdentityKey() === senderKey) {
|
||||||
|
this.senderDeviceCache.set(senderKey, device.deviceId);
|
||||||
|
return device.deviceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full (decrypted) event object the crate's dispatch reads. */
|
||||||
|
function rawEvent(event: MatrixEvent): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...(event.event as Record<string, unknown>),
|
||||||
|
type: event.getType(),
|
||||||
|
content: event.getContent(),
|
||||||
|
sender: event.getSender(),
|
||||||
|
event_id: event.getId(),
|
||||||
|
room_id: event.getRoomId(),
|
||||||
|
origin_server_ts: event.getTs(),
|
||||||
|
state_key: event.getStateKey(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MSC4195 member claims, as the crate serialises them. */
|
||||||
|
interface MemberClaims {
|
||||||
|
id: string;
|
||||||
|
claimed_user_id: string;
|
||||||
|
claimed_device_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The device a media key message claims to come from, in either dialect. */
|
||||||
|
interface KeyMessageContent {
|
||||||
|
member?: { claimed_device_id?: string };
|
||||||
|
device_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimedKeyDevice(content: KeyMessageContent): string | undefined {
|
||||||
|
return content.member?.claimed_device_id ?? content.device_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The device a 2025-dialect membership claims (`member.device_id`). */
|
||||||
|
function claimedMemberDevice(
|
||||||
|
content: Record<string, unknown>,
|
||||||
|
): string | undefined {
|
||||||
|
const member = content.member as { device_id?: unknown } | undefined;
|
||||||
|
return typeof member?.device_id === "string" ? member.device_id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map js-sdk / HTTP failures onto the error the crate reasons about. */
|
||||||
|
function toRtcError(error: unknown): Error {
|
||||||
|
if (RtcError.instanceOf(error)) return error;
|
||||||
|
if (
|
||||||
|
error instanceof UnsupportedDelayedEventsEndpointError ||
|
||||||
|
error instanceof UnsupportedStickyEventsEndpointError
|
||||||
|
)
|
||||||
|
return new RtcError.Unsupported(String(error));
|
||||||
|
if (error instanceof MatrixError) {
|
||||||
|
if (error.errcode === "M_LIMIT_EXCEEDED")
|
||||||
|
return new RtcError.RateLimited({
|
||||||
|
retryAfterMs:
|
||||||
|
typeof error.data.retry_after_ms === "number"
|
||||||
|
? BigInt(error.data.retry_after_ms)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
if (error.httpStatus === 404 || error.errcode === "M_UNRECOGNIZED")
|
||||||
|
return new RtcError.Unsupported(String(error));
|
||||||
|
if (error.httpStatus === 403 || error.errcode === "M_FORBIDDEN")
|
||||||
|
return new RtcError.Rejected(String(error));
|
||||||
|
return new RtcError.Http(String(error));
|
||||||
|
}
|
||||||
|
return new RtcError.Driver(String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guard<T>(f: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await f();
|
||||||
|
} catch (error) {
|
||||||
|
throw toRtcError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Fakes shared by the js-sdk driver tests. The drivers tell the two client
|
||||||
|
// kinds apart by prototype, so each fake is created on the real prototype
|
||||||
|
// and given only the members it needs; EventEmitter sits at the bottom of
|
||||||
|
// that chain, so `on`/`emit` work.
|
||||||
|
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import {
|
||||||
|
MatrixClient,
|
||||||
|
type Room,
|
||||||
|
RoomWidgetClient,
|
||||||
|
SyncState,
|
||||||
|
User,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
export const ROOM_ID = "!room:example.org";
|
||||||
|
export const ME = "@me:example.org";
|
||||||
|
export const MY_DEVICE = "MYDEV";
|
||||||
|
export const LK = "https://lk.example.org";
|
||||||
|
|
||||||
|
export const openIdToken = {
|
||||||
|
access_token: "openid",
|
||||||
|
expires_in: 3600,
|
||||||
|
matrix_server_name: "example.org",
|
||||||
|
token_type: "Bearer",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Fn = ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
export interface FakeClient extends EventEmitter {
|
||||||
|
_unstable_sendStickyEvent: Fn;
|
||||||
|
_unstable_sendDelayedEvent: Fn;
|
||||||
|
_unstable_sendStickyDelayedEvent: Fn;
|
||||||
|
_unstable_sendDelayedStateEvent: Fn;
|
||||||
|
_unstable_updateDelayedEvent: Fn;
|
||||||
|
_unstable_getRTCTransports: Fn;
|
||||||
|
sendStateEvent: Fn;
|
||||||
|
encryptAndSendToDevice: Fn;
|
||||||
|
sendEvent: Fn;
|
||||||
|
redactEvent: Fn;
|
||||||
|
getSyncState: Fn;
|
||||||
|
getUser: (userId: string) => User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FakeRoom extends EventEmitter {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientMembers(widget: boolean): Record<string, unknown> {
|
||||||
|
const user = new User(ME);
|
||||||
|
user.rawDisplayName = "Me";
|
||||||
|
user.avatarUrl = "mxc://example.org/me";
|
||||||
|
const verification = { signedByOwner: true };
|
||||||
|
return {
|
||||||
|
baseUrl: "https://hs.example.org",
|
||||||
|
getUserId: () => ME,
|
||||||
|
getDeviceId: () => MY_DEVICE,
|
||||||
|
getAccessToken: () => (widget ? null : "token"),
|
||||||
|
getCrypto: () =>
|
||||||
|
widget
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
getVersion: () => "fake 1.0",
|
||||||
|
getDeviceVerificationStatus: async () =>
|
||||||
|
Promise.resolve(verification),
|
||||||
|
getUserDeviceInfo: async () => Promise.resolve(new Map()),
|
||||||
|
},
|
||||||
|
getSyncState: vi.fn(() => SyncState.Syncing),
|
||||||
|
getUser: () => user,
|
||||||
|
getOpenIdToken: async () => Promise.resolve(openIdToken),
|
||||||
|
decryptEventIfNeeded: async () => Promise.resolve(),
|
||||||
|
doesServerSupportUnstableFeature: async () => Promise.resolve(true),
|
||||||
|
mxcUrlToHttp: (mxc: string) => `https://hs.example.org/media/${mxc}`,
|
||||||
|
_unstable_sendStickyEvent: vi.fn(async () =>
|
||||||
|
Promise.resolve({ event_id: "$sticky" }),
|
||||||
|
),
|
||||||
|
_unstable_sendDelayedEvent: vi.fn(async () =>
|
||||||
|
Promise.resolve({ delay_id: "delay-plain" }),
|
||||||
|
),
|
||||||
|
_unstable_sendStickyDelayedEvent: vi.fn(async () =>
|
||||||
|
Promise.resolve({ delay_id: "delay-sticky" }),
|
||||||
|
),
|
||||||
|
_unstable_sendDelayedStateEvent: vi.fn(async () =>
|
||||||
|
Promise.resolve({ delay_id: "delay-state" }),
|
||||||
|
),
|
||||||
|
_unstable_updateDelayedEvent: vi.fn(async () => Promise.resolve({})),
|
||||||
|
_unstable_getRTCTransports: vi.fn(async () => Promise.resolve([])),
|
||||||
|
sendStateEvent: vi.fn(async () => Promise.resolve({ event_id: "$state" })),
|
||||||
|
encryptAndSendToDevice: vi.fn(async () => Promise.resolve()),
|
||||||
|
sendEvent: vi.fn(async () => Promise.resolve({ event_id: "$sent" })),
|
||||||
|
redactEvent: vi.fn(async () => Promise.resolve({ event_id: "$redaction" })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fakeRoom(): FakeRoom {
|
||||||
|
const room = new EventEmitter() as FakeRoom;
|
||||||
|
const alice = {
|
||||||
|
userId: "@a:example.org",
|
||||||
|
rawDisplayName: "Alice",
|
||||||
|
getMxcAvatarUrl: () => "mxc://example.org/alice",
|
||||||
|
};
|
||||||
|
const bob = {
|
||||||
|
userId: "@b:example.org",
|
||||||
|
rawDisplayName: undefined,
|
||||||
|
getMxcAvatarUrl: () => undefined,
|
||||||
|
};
|
||||||
|
Object.assign(room, {
|
||||||
|
roomId: ROOM_ID,
|
||||||
|
name: "Standup",
|
||||||
|
getCanonicalAlias: () => "#standup:example.org",
|
||||||
|
getMxcAvatarUrl: () => "mxc://example.org/room",
|
||||||
|
hasEncryptionStateEvent: () => true,
|
||||||
|
currentState: {
|
||||||
|
getJoinRule: () => "public",
|
||||||
|
getStateEvents: () => [],
|
||||||
|
maySendStateEvent: () => true,
|
||||||
|
},
|
||||||
|
getMembersWithMembership: (membership: string) =>
|
||||||
|
membership === "join" ? [alice] : [bob],
|
||||||
|
_unstable_getStickyEvents: () => [],
|
||||||
|
relations: { getChildEventsForEvent: () => undefined },
|
||||||
|
});
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fakeClient(widget: boolean): FakeClient {
|
||||||
|
const client = Object.create(
|
||||||
|
widget ? RoomWidgetClient.prototype : MatrixClient.prototype,
|
||||||
|
) as FakeClient;
|
||||||
|
Object.assign(client, clientMembers(widget));
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const asClient = (client: FakeClient): MatrixClient =>
|
||||||
|
client as unknown as MatrixClient;
|
||||||
|
export const asRoom = (room: FakeRoom): Room => room as unknown as Room;
|
||||||
|
|
||||||
|
export function jsonResponse(body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { type Behavior } from "../state/Behavior";
|
||||||
|
import { type ObservableScope } from "../state/ObservableScope";
|
||||||
|
import { type Unsubscribe } from "./ElementCallMatrixClientDriver";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns one of a driver's `get` / `subscribe` pairs into a behavior owned by
|
||||||
|
* `scope`: the current value now, every change until the scope ends.
|
||||||
|
*/
|
||||||
|
export function observeDriver<T>(
|
||||||
|
scope: ObservableScope,
|
||||||
|
get: () => T,
|
||||||
|
subscribe: (listener: (value: T) => void) => Unsubscribe,
|
||||||
|
): Behavior<T> {
|
||||||
|
return scope.behavior(
|
||||||
|
new Observable<T>((subscriber) =>
|
||||||
|
subscribe((value) => subscriber.next(value)),
|
||||||
|
),
|
||||||
|
get(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+2
@@ -0,0 +1,2 @@
|
|||||||
|
matrix-rtc (MatrixSdkArchitectureDraft) a095ba7-dirty
|
||||||
|
built 2026-09-15T16:54:50Z by scripts/sync-matrix-rtc-sdk.sh
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
// This file was autogenerated by some hot garbage in the `uniffi-bindgen-react-native` crate.
|
||||||
|
// Trust me, you don't want to mess with it!
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// Structs and function types for calling back into Typescript from Rust.
|
||||||
|
export type UniffiRustFutureContinuationCallback = (data: bigint, pollResult: number) => void;
|
||||||
|
export type UniffiForeignFutureDroppedCallback = (handle: bigint) => void;
|
||||||
|
export type UniffiForeignFutureDroppedCallbackStruct = {
|
||||||
|
handle: bigint;
|
||||||
|
free: UniffiForeignFutureDroppedCallback;
|
||||||
|
};
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcConnectionsListenerMethod0 = (uniffiHandle: bigint, connections: Uint8Array) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceCloneMatrixRtcConnectionsListener = (handle: bigint) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceFreeMatrixRtcConnectionsListener = (handle: bigint) => void;
|
||||||
|
export type UniffiVTableCallbackInterfaceMatrixRtcConnectionsListener = {
|
||||||
|
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcConnectionsListener;
|
||||||
|
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcConnectionsListener;
|
||||||
|
on_connections_change: UniffiCallbackInterfaceMatrixRtcConnectionsListenerMethod0;
|
||||||
|
};
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcKeyMapListenerMethod0 = (uniffiHandle: bigint, keyMap: Uint8Array, change: Uint8Array) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceCloneMatrixRtcKeyMapListener = (handle: bigint) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceFreeMatrixRtcKeyMapListener = (handle: bigint) => void;
|
||||||
|
export type UniffiVTableCallbackInterfaceMatrixRtcKeyMapListener = {
|
||||||
|
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcKeyMapListener;
|
||||||
|
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcKeyMapListener;
|
||||||
|
on_key_map_change: UniffiCallbackInterfaceMatrixRtcKeyMapListenerMethod0;
|
||||||
|
};
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcKeyRejectedListenerMethod0 = (uniffiHandle: bigint, memberId: Uint8Array, reason: Uint8Array) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceCloneMatrixRtcKeyRejectedListener = (handle: bigint) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceFreeMatrixRtcKeyRejectedListener = (handle: bigint) => void;
|
||||||
|
export type UniffiVTableCallbackInterfaceMatrixRtcKeyRejectedListener = {
|
||||||
|
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcKeyRejectedListener;
|
||||||
|
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcKeyRejectedListener;
|
||||||
|
on_key_rejected: UniffiCallbackInterfaceMatrixRtcKeyRejectedListenerMethod0;
|
||||||
|
};
|
||||||
|
export type UniffiForeignFutureResultRustBuffer = {
|
||||||
|
return_value: Uint8Array;
|
||||||
|
call_status: UniffiRustCallStatus;
|
||||||
|
};
|
||||||
|
export type UniffiForeignFutureCompleterustBuffer = (callbackData: bigint, result: UniffiForeignFutureResultRustBuffer) => void;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod0 = (uniffiHandle: bigint, roomId: Uint8Array, eventType: Uint8Array, contentJson: Uint8Array, durationMs: bigint, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod1 = (uniffiHandle: bigint, roomId: Uint8Array, eventType: Uint8Array, stateKey: Uint8Array, contentJson: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod2 = (uniffiHandle: bigint, roomId: Uint8Array, eventType: Uint8Array, contentJson: Uint8Array, delayMs: bigint, stickyDurationMs: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod3 = (uniffiHandle: bigint, roomId: Uint8Array, eventType: Uint8Array, stateKey: Uint8Array, contentJson: Uint8Array, delayMs: bigint, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
export type UniffiForeignFutureResultVoid = {
|
||||||
|
call_status: UniffiRustCallStatus;
|
||||||
|
};
|
||||||
|
export type UniffiForeignFutureCompletevoid = (callbackData: bigint, result: UniffiForeignFutureResultVoid) => void;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod4 = (uniffiHandle: bigint, roomId: Uint8Array, delayId: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompletevoid, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod5 = (uniffiHandle: bigint, roomId: Uint8Array, delayId: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompletevoid, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod6 = (uniffiHandle: bigint, roomId: Uint8Array, slotId: Uint8Array, memberJson: Uint8Array, delayId: Uint8Array, livekitServiceUrl: Uint8Array, delayMs: bigint, uniffiFutureCallback: UniffiForeignFutureCompletevoid, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod7 = (uniffiHandle: bigint, recipients: Uint8Array, eventType: Uint8Array, contentJson: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod8 = (uniffiHandle: bigint, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod9 = (uniffiHandle: bigint, request: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod10 = (uniffiHandle: bigint, eventType: Uint8Array, stateKey: Uint8Array, limit: number, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod11 = (uniffiHandle: bigint, eventType: Uint8Array, stateKey: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod12 = (uniffiHandle: bigint, sink: bigint) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod13 = (uniffiHandle: bigint, sink: bigint) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod14 = (uniffiHandle: bigint, sink: bigint) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod15 = (uniffiHandle: bigint) => number;
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod16 = (uniffiHandle: bigint, sink: bigint) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceCloneMatrixRtcMatrixDriverCallback = (handle: bigint) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceFreeMatrixRtcMatrixDriverCallback = (handle: bigint) => void;
|
||||||
|
export type UniffiVTableCallbackInterfaceMatrixRtcMatrixDriverCallback = {
|
||||||
|
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcMatrixDriverCallback;
|
||||||
|
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcMatrixDriverCallback;
|
||||||
|
send_sticky_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod0;
|
||||||
|
send_state_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod1;
|
||||||
|
send_delayed_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod2;
|
||||||
|
send_delayed_state_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod3;
|
||||||
|
restart_delayed_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod4;
|
||||||
|
cancel_delayed_event: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod5;
|
||||||
|
delegate_livekit_delayed_leave: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod6;
|
||||||
|
send_to_device: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod7;
|
||||||
|
get_rtc_transports: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod8;
|
||||||
|
get_livekit_token: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod9;
|
||||||
|
read_events: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod10;
|
||||||
|
read_state: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod11;
|
||||||
|
subscribe_room_events: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod12;
|
||||||
|
subscribe_to_device_events: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod13;
|
||||||
|
subscribe_state_updates: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod14;
|
||||||
|
is_homeserver_connected: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod15;
|
||||||
|
subscribe_connectivity: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod16;
|
||||||
|
};
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcMembershipsListenerMethod0 = (uniffiHandle: bigint, memberships: Uint8Array) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceCloneMatrixRtcMembershipsListener = (handle: bigint) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceFreeMatrixRtcMembershipsListener = (handle: bigint) => void;
|
||||||
|
export type UniffiVTableCallbackInterfaceMatrixRtcMembershipsListener = {
|
||||||
|
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcMembershipsListener;
|
||||||
|
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcMembershipsListener;
|
||||||
|
on_memberships_change: UniffiCallbackInterfaceMatrixRtcMembershipsListenerMethod0;
|
||||||
|
};
|
||||||
|
type UniffiCallbackInterfaceMatrixRtcStatusListenerMethod0 = (uniffiHandle: bigint, status: Uint8Array) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceCloneMatrixRtcStatusListener = (handle: bigint) => UniffiResult<void>;
|
||||||
|
type UniffiCallbackInterfaceFreeMatrixRtcStatusListener = (handle: bigint) => void;
|
||||||
|
export type UniffiVTableCallbackInterfaceMatrixRtcStatusListener = {
|
||||||
|
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcStatusListener;
|
||||||
|
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcStatusListener;
|
||||||
|
on_status_change: UniffiCallbackInterfaceMatrixRtcStatusListenerMethod0;
|
||||||
|
};
|
||||||
+8716
File diff suppressed because it is too large
Load Diff
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Hand-written declaration for the wasm-bindgen glue next to it, which ubrn
|
||||||
|
// emits as plain JavaScript. Only the loading entry points are declared; the
|
||||||
|
// exported `ubrn_*` FFI functions are reached through matrix_rtc.ts alone.
|
||||||
|
// Kept out of the sync script's way: it is not generated.
|
||||||
|
|
||||||
|
/** What wasm-bindgen's loader accepts as the module to instantiate. */
|
||||||
|
export type InitInput =
|
||||||
|
| RequestInfo
|
||||||
|
| URL
|
||||||
|
| Response
|
||||||
|
| BufferSource
|
||||||
|
| WebAssembly.Module;
|
||||||
|
|
||||||
|
export default function initAsync(
|
||||||
|
moduleOrPath?:
|
||||||
|
| { module_or_path?: InitInput | Promise<InitInput> }
|
||||||
|
| InitInput
|
||||||
|
| Promise<InitInput>,
|
||||||
|
): Promise<unknown>;
|
||||||
|
|
||||||
|
export function initSync(
|
||||||
|
module: { module: BufferSource | WebAssembly.Module } | BufferSource | WebAssembly.Module,
|
||||||
|
): unknown;
|
||||||
+3049
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
|
||||||
|
import {
|
||||||
|
FfiElementCallCompat,
|
||||||
|
FfiMatrixDriver,
|
||||||
|
FfiParticipationManager,
|
||||||
|
FfiStatus,
|
||||||
|
computeSessionsFromEvents,
|
||||||
|
type FfiLivekitToken,
|
||||||
|
type FfiLivekitTokenRequest,
|
||||||
|
type FfiRtcTransport,
|
||||||
|
type FfiSendEventResponse,
|
||||||
|
type FfiToDeviceDelivery,
|
||||||
|
type FfiToDeviceRecipient,
|
||||||
|
type MatrixDriverCallback,
|
||||||
|
} from ".";
|
||||||
|
|
||||||
|
const ROOM_ID = "!room:example.org";
|
||||||
|
|
||||||
|
describe("matrix-rtc-sdk", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initMatrixRtcSdkForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes a session from raw events without a manager", () => {
|
||||||
|
const join = JSON.stringify({
|
||||||
|
type: "m.rtc.member",
|
||||||
|
sender: "@alice:example.org",
|
||||||
|
event_id: "$1",
|
||||||
|
room_id: ROOM_ID,
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
msc4354_sticky: { duration_ms: 240_000 },
|
||||||
|
content: {
|
||||||
|
slot_id: "m.call#ROOM",
|
||||||
|
msc4354_sticky_key: "m-1",
|
||||||
|
member: { id: "m-1", membership: "join" },
|
||||||
|
application: { type: "m.call" },
|
||||||
|
transports: {
|
||||||
|
published: [
|
||||||
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
|
],
|
||||||
|
can_subscribe: ["livekit"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [session] = computeSessionsFromEvents(
|
||||||
|
[join],
|
||||||
|
FfiElementCallCompat.StickyEvents,
|
||||||
|
);
|
||||||
|
expect(session.memberCount).toBe(1);
|
||||||
|
expect(session.members[0].eventId).toBe("$1");
|
||||||
|
expect(session.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs a manager over a TypeScript driver and starts disconnected", () => {
|
||||||
|
const driver = new FfiMatrixDriver(new InertDriver());
|
||||||
|
const manager = new FfiParticipationManager(
|
||||||
|
ROOM_ID,
|
||||||
|
"m.call#ROOM",
|
||||||
|
"@me:example.org",
|
||||||
|
"MYDEV",
|
||||||
|
driver,
|
||||||
|
{
|
||||||
|
compat: FfiElementCallCompat.StickyEvents,
|
||||||
|
manageMediaKeys: false,
|
||||||
|
requireCrossSignedSender: false,
|
||||||
|
useKeyDelayMs: 1000n,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(FfiStatus.Disconnected.instanceOf(manager.status())).toBe(true);
|
||||||
|
expect(manager.memberships()).toEqual([]);
|
||||||
|
expect(manager.ownTransportIdentity()).toBeUndefined();
|
||||||
|
manager.uniffiDestroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A driver that answers every read with nothing and never sends. */
|
||||||
|
class InertDriver implements MatrixDriverCallback {
|
||||||
|
public async sendStickyEvent(): Promise<FfiSendEventResponse> {
|
||||||
|
return Promise.resolve({ eventId: "$sticky", delayId: undefined });
|
||||||
|
}
|
||||||
|
public async sendStateEvent(): Promise<FfiSendEventResponse> {
|
||||||
|
return Promise.resolve({ eventId: "$state", delayId: undefined });
|
||||||
|
}
|
||||||
|
public async sendDelayedEvent(): Promise<string> {
|
||||||
|
return Promise.resolve("delay");
|
||||||
|
}
|
||||||
|
public async sendDelayedStateEvent(): Promise<string> {
|
||||||
|
return Promise.resolve("delay");
|
||||||
|
}
|
||||||
|
public async restartDelayedEvent(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
public async cancelDelayedEvent(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
public async delegateLivekitDelayedLeave(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
public async sendToDevice(
|
||||||
|
recipients: FfiToDeviceRecipient[],
|
||||||
|
): Promise<FfiToDeviceDelivery[]> {
|
||||||
|
return Promise.resolve(
|
||||||
|
recipients.map((recipient) => ({ recipient, error: undefined })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
public async getLivekitToken(
|
||||||
|
request: FfiLivekitTokenRequest,
|
||||||
|
): Promise<FfiLivekitToken> {
|
||||||
|
return Promise.resolve({ jwt: "jwt", url: request.url });
|
||||||
|
}
|
||||||
|
public async readEvents(): Promise<string[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
public async readState(): Promise<string[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
public subscribeRoomEvents(): void {}
|
||||||
|
public subscribeToDeviceEvents(): void {}
|
||||||
|
public subscribeStateUpdates(): void {}
|
||||||
|
public subscribeConnectivity(): void {}
|
||||||
|
public isHomeserverConnected(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MatrixRTC SDK: the Rust `matrix-rtc` crate through its uniffi web
|
||||||
|
* bindings. Everything Element Call needs to *participate* in a MatrixRTC
|
||||||
|
* session — the session projection, our own membership with its keep-alive,
|
||||||
|
* transport tokens and the media key exchange — lives in the crate; this
|
||||||
|
* module loads it and re-exports the surface Element Call uses.
|
||||||
|
*
|
||||||
|
* The bindings under `generated/` are vendored by
|
||||||
|
* `scripts/sync-matrix-rtc-sdk.sh` and never edited by hand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import initAsync, { type InitInput } from "./generated/wasm-bindgen/index.js";
|
||||||
|
import bindings from "./generated/matrix_rtc";
|
||||||
|
|
||||||
|
export {
|
||||||
|
FfiMatrixDriver,
|
||||||
|
FfiParticipationManager,
|
||||||
|
FfiElementCallCompat,
|
||||||
|
FfiEventOrigin,
|
||||||
|
FfiStatus,
|
||||||
|
FfiDisconnectCause,
|
||||||
|
FfiJoinError,
|
||||||
|
FfiKeepAlive,
|
||||||
|
FfiMembershipState,
|
||||||
|
FfiTransportIntent,
|
||||||
|
RtcError,
|
||||||
|
computeSessionsFromEvents,
|
||||||
|
} from "./generated/matrix_rtc";
|
||||||
|
export type {
|
||||||
|
FfiConnectionData,
|
||||||
|
FfiConnectionWithMembers,
|
||||||
|
FfiJoinParams,
|
||||||
|
FfiLivekitToken,
|
||||||
|
FfiLivekitTokenRequest,
|
||||||
|
FfiMediaKey,
|
||||||
|
FfiMember,
|
||||||
|
FfiMembership,
|
||||||
|
FfiParticipationConfig,
|
||||||
|
FfiRtcTransport,
|
||||||
|
FfiSendEventResponse,
|
||||||
|
FfiSessionSnapshot,
|
||||||
|
FfiToDeviceDelivery,
|
||||||
|
FfiToDeviceRecipient,
|
||||||
|
ConnectivitySinkLike,
|
||||||
|
MatrixDriverCallback,
|
||||||
|
RoomEventSinkLike,
|
||||||
|
StateUpdateSinkLike,
|
||||||
|
ToDeviceSinkLike,
|
||||||
|
} from "./generated/matrix_rtc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to load the wasm from. A URL (or anything `fetch` accepts) in a
|
||||||
|
* browser; bytes or a compiled module where there is nothing to fetch from,
|
||||||
|
* such as tests.
|
||||||
|
*/
|
||||||
|
export type MatrixRtcWasmSource = InitInput;
|
||||||
|
|
||||||
|
let loading: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and initialises the SDK. Idempotent: the first call decides the
|
||||||
|
* source, later calls await the same load.
|
||||||
|
*
|
||||||
|
* Without a `source`, the wasm is the one bundled next to this module (an
|
||||||
|
* asset URL in the app builds). A host that serves the file from somewhere
|
||||||
|
* else, or a test runner with no server, passes its own.
|
||||||
|
*/
|
||||||
|
export async function initMatrixRtcSdk(
|
||||||
|
source?: MatrixRtcWasmSource,
|
||||||
|
): Promise<void> {
|
||||||
|
loading ??= (async (): Promise<void> => {
|
||||||
|
await initAsync({ module_or_path: source ?? (await bundledWasm()) });
|
||||||
|
bindings.initialize();
|
||||||
|
})();
|
||||||
|
await loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wasm as the bundler placed it, resolved lazily so nothing is fetched until needed. */
|
||||||
|
async function bundledWasm(): Promise<string> {
|
||||||
|
const { default: url } =
|
||||||
|
await import("./generated/wasm-bindgen/index_bg.wasm?url");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { MatrixRTCMode } from "../../config/ConfigOptions";
|
||||||
|
import {
|
||||||
|
MOCK_LK_SERVICE_URL,
|
||||||
|
MockRtcMatrixDriver,
|
||||||
|
roomEncryptionEvent,
|
||||||
|
slotEvent,
|
||||||
|
waitFor,
|
||||||
|
} from "../../driver/MockRtcMatrixDriver";
|
||||||
|
import {
|
||||||
|
FfiDisconnectCause,
|
||||||
|
FfiElementCallCompat,
|
||||||
|
FfiStatus,
|
||||||
|
type FfiMediaKey,
|
||||||
|
} from "../../matrix-rtc-sdk";
|
||||||
|
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
|
||||||
|
import { testScope } from "../../utils/test";
|
||||||
|
import { ObservableScope } from "../ObservableScope";
|
||||||
|
import { CallParticipation } from "./CallParticipation";
|
||||||
|
import { errorForStatus } from "./errors";
|
||||||
|
import {
|
||||||
|
compatForMode,
|
||||||
|
joinParamsFromConfig,
|
||||||
|
participationConfig,
|
||||||
|
} from "./joinParams";
|
||||||
|
import { publishOnLivekit, receiveOnly } from "./transportIntent";
|
||||||
|
import {
|
||||||
|
MatrixRTCTransportMissingError,
|
||||||
|
NoOpenSlotError,
|
||||||
|
StickyEventsRequiredError,
|
||||||
|
} from "../../utils/errors";
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
delayed_leave: { delay_ms: 15_000 },
|
||||||
|
delegated_delayed_leave: { delay_ms: 3_600_000 },
|
||||||
|
network_error_retry_ms: 1000,
|
||||||
|
wait_for_key_rotation_ms: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinParams = joinParamsFromConfig({
|
||||||
|
session,
|
||||||
|
delegateDelayedLeave: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function create(
|
||||||
|
driver: MockRtcMatrixDriver,
|
||||||
|
overrides: { manageMediaKeys?: boolean; transportFallbackUrl?: string } = {},
|
||||||
|
scope = testScope(),
|
||||||
|
): CallParticipation {
|
||||||
|
return new CallParticipation(
|
||||||
|
scope,
|
||||||
|
driver,
|
||||||
|
driver.roomId,
|
||||||
|
driver.userId,
|
||||||
|
driver.deviceId,
|
||||||
|
{
|
||||||
|
config: participationConfig({
|
||||||
|
mode: MatrixRTCMode.Matrix_2_0,
|
||||||
|
manageMediaKeys: overrides.manageMediaKeys ?? true,
|
||||||
|
session,
|
||||||
|
}),
|
||||||
|
transportFallbackUrl: overrides.transportFallbackUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A room where nobody has started a call yet, and we may. */
|
||||||
|
const openSlot = { encrypted: false, canOpen: true };
|
||||||
|
|
||||||
|
const peer = {
|
||||||
|
userId: "@peer:example.org",
|
||||||
|
deviceId: "PEERDEV",
|
||||||
|
memberId: "m-peer",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CallParticipation", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initMatrixRtcSdkForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts disconnected and follows a remote member in and out", async () => {
|
||||||
|
// somebody already started the call: the slot is open
|
||||||
|
const driver = new MockRtcMatrixDriver({
|
||||||
|
roomState: [slotEvent({ status: "open" })],
|
||||||
|
});
|
||||||
|
const callParticipation = create(driver);
|
||||||
|
expect(
|
||||||
|
FfiStatus.Disconnected.instanceOf(callParticipation.status$.value),
|
||||||
|
).toBe(true);
|
||||||
|
expect(callParticipation.memberships$.value.value).toEqual([]);
|
||||||
|
|
||||||
|
driver.peerJoins(peer);
|
||||||
|
// getters are fresh at once; the listener fires a tick later
|
||||||
|
await waitFor(
|
||||||
|
"peer listed",
|
||||||
|
() => callParticipation.memberships$.value.value.length === 1,
|
||||||
|
);
|
||||||
|
const [membership] = callParticipation.memberships$.value.value;
|
||||||
|
expect(membership.member.memberId).toBe(peer.memberId);
|
||||||
|
expect(membership.connections).toEqual([MOCK_LK_SERVICE_URL]);
|
||||||
|
expect(callParticipation.session$.value.memberCount).toBe(1);
|
||||||
|
|
||||||
|
driver.peerLeaves(peer);
|
||||||
|
await waitFor(
|
||||||
|
"peer gone",
|
||||||
|
() => callParticipation.memberships$.value.value.length === 0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins publishing, exposes our identity before the echo and our membership after it", async () => {
|
||||||
|
const driver = new MockRtcMatrixDriver();
|
||||||
|
const callParticipation = create(driver);
|
||||||
|
await callParticipation.join(publishOnLivekit(), joinParams, openSlot);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
FfiStatus.Connected.instanceOf(callParticipation.status$.value),
|
||||||
|
).toBe(true);
|
||||||
|
const ownMemberId = callParticipation.ownMemberId$.value;
|
||||||
|
expect(ownMemberId).toBeTruthy();
|
||||||
|
expect(callParticipation.ownTransportIdentity$.value).toBeTruthy();
|
||||||
|
// nobody had started a call: we opened the slot before joining
|
||||||
|
expect(callParticipation.session$.value.slotOpen).toBe(true);
|
||||||
|
const [slot] = driver.calls("stateEvent");
|
||||||
|
expect(slot.eventType).toBe("org.matrix.msc4143.rtc.slot");
|
||||||
|
expect(slot.stateKey).toBe("m.call#ROOM");
|
||||||
|
expect(slot.content).toEqual({
|
||||||
|
status: "open",
|
||||||
|
application: { type: "m.call" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
"own echo",
|
||||||
|
() => callParticipation.ownMembership$.value !== null,
|
||||||
|
);
|
||||||
|
expect(callParticipation.ownMembership$.value?.member.memberId).toBe(
|
||||||
|
ownMemberId,
|
||||||
|
);
|
||||||
|
expect(callParticipation.ownMembership$.value?.transportIdentity).toBe(
|
||||||
|
callParticipation.ownTransportIdentity$.value,
|
||||||
|
);
|
||||||
|
expect(callParticipation.connections$.value).toHaveLength(1);
|
||||||
|
expect(callParticipation.connections$.value[0].connection.serviceUrl).toBe(
|
||||||
|
MOCK_LK_SERVICE_URL,
|
||||||
|
);
|
||||||
|
// discovery: the bare intent asked the homeserver
|
||||||
|
expect(driver.calls("getRtcTransports")).toHaveLength(1);
|
||||||
|
expect(driver.calls("getLivekitToken")[0].slotId).toBe("m.call#ROOM");
|
||||||
|
|
||||||
|
await callParticipation.leave();
|
||||||
|
const status = callParticipation.status$.value;
|
||||||
|
expect(FfiStatus.Disconnected.instanceOf(status)).toBe(true);
|
||||||
|
if (FfiStatus.Disconnected.instanceOf(status))
|
||||||
|
expect(FfiDisconnectCause.LeftByHost.instanceOf(status.inner.cause)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// ...and again: a manager can be reused, with a fresh member id
|
||||||
|
await callParticipation.join(publishOnLivekit(), joinParams, openSlot);
|
||||||
|
expect(callParticipation.ownMemberId$.value).not.toBe(ownMemberId);
|
||||||
|
await callParticipation.leave();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open a slot that is already open, and refuses to join without the power to open one", async () => {
|
||||||
|
const open = new MockRtcMatrixDriver({
|
||||||
|
roomState: [slotEvent({ status: "open" })],
|
||||||
|
});
|
||||||
|
const p1 = create(open);
|
||||||
|
await p1.join(receiveOnly(), joinParams, {
|
||||||
|
encrypted: false,
|
||||||
|
canOpen: false,
|
||||||
|
});
|
||||||
|
expect(open.calls("stateEvent")).toEqual([]);
|
||||||
|
await p1.leave();
|
||||||
|
|
||||||
|
const closed = new MockRtcMatrixDriver();
|
||||||
|
const p2 = create(closed);
|
||||||
|
await expect(
|
||||||
|
p2.join(receiveOnly(), joinParams, { encrypted: false, canOpen: false }),
|
||||||
|
).rejects.toBeInstanceOf(NoOpenSlotError);
|
||||||
|
expect(closed.calls("stateEvent")).toEqual([]);
|
||||||
|
expect(closed.calls("stickyEvent")).toEqual([]);
|
||||||
|
expect(FfiStatus.Disconnected.instanceOf(p2.status$.value)).toBe(true);
|
||||||
|
|
||||||
|
// an encrypted room gets a slot that prescribes per-member keys
|
||||||
|
const encryptedRoom = new MockRtcMatrixDriver({
|
||||||
|
roomState: [roomEncryptionEvent()],
|
||||||
|
});
|
||||||
|
const p3 = create(encryptedRoom);
|
||||||
|
await p3.join(receiveOnly(), joinParams, {
|
||||||
|
encrypted: true,
|
||||||
|
canOpen: true,
|
||||||
|
});
|
||||||
|
expect(encryptedRoom.calls("stateEvent")[0].content).toEqual({
|
||||||
|
status: "open",
|
||||||
|
application: { type: "m.call" },
|
||||||
|
encryption: { type: "m.per_member" },
|
||||||
|
});
|
||||||
|
expect(p3.session$.value.encrypted).toBe(true);
|
||||||
|
await p3.leave();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the configured transport when the homeserver has none or fails", async () => {
|
||||||
|
const none = new MockRtcMatrixDriver({ transports: [] });
|
||||||
|
const p1 = create(none, { transportFallbackUrl: "https://lk.config" });
|
||||||
|
await p1.join(publishOnLivekit(), joinParams, openSlot);
|
||||||
|
expect(none.calls("getLivekitToken")[0].url).toBe("https://lk.config");
|
||||||
|
await p1.leave();
|
||||||
|
|
||||||
|
const failing = new MockRtcMatrixDriver();
|
||||||
|
failing.failTransportDiscovery = true;
|
||||||
|
const p2 = create(failing, { transportFallbackUrl: "https://lk.config" });
|
||||||
|
await p2.join(publishOnLivekit(), joinParams, openSlot);
|
||||||
|
expect(failing.calls("getLivekitToken")[0].url).toBe("https://lk.config");
|
||||||
|
await p2.leave();
|
||||||
|
|
||||||
|
// a custom URL in the intent skips discovery altogether
|
||||||
|
const custom = new MockRtcMatrixDriver();
|
||||||
|
const p3 = create(custom);
|
||||||
|
await p3.join(publishOnLivekit("https://lk.custom"), joinParams, openSlot);
|
||||||
|
expect(custom.calls("getRtcTransports")).toHaveLength(0);
|
||||||
|
expect(custom.calls("getLivekitToken")[0].url).toBe("https://lk.custom");
|
||||||
|
await p3.leave();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams key changes and filters members that left holding our key", async () => {
|
||||||
|
const driver = new MockRtcMatrixDriver({
|
||||||
|
roomState: [
|
||||||
|
roomEncryptionEvent(),
|
||||||
|
slotEvent({ status: "open", encrypted: true }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const callParticipation = create(driver);
|
||||||
|
const changes: FfiMediaKey[] = [];
|
||||||
|
callParticipation.keyChanges$.subscribe((k) => changes.push(k));
|
||||||
|
driver.addPeer(peer);
|
||||||
|
await callParticipation.join(receiveOnly(), joinParams, openSlot);
|
||||||
|
driver.peerJoins(peer);
|
||||||
|
await waitFor("peer key", () =>
|
||||||
|
callParticipation.keyMap$.value.some((k) => k.memberId === peer.memberId),
|
||||||
|
);
|
||||||
|
expect(changes.some((k) => k.memberId === peer.memberId)).toBe(true);
|
||||||
|
driver.peerLeaves(peer);
|
||||||
|
// the crate keeps a LeftWithKeys entry; the behavior does not
|
||||||
|
await waitFor(
|
||||||
|
"peer gone from memberships",
|
||||||
|
() =>
|
||||||
|
!callParticipation.memberships$.value.value.some(
|
||||||
|
(m) => m.member.memberId === peer.memberId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await callParticipation.leave();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not exchange keys when the call manages none", async () => {
|
||||||
|
const driver = new MockRtcMatrixDriver();
|
||||||
|
const callParticipation = create(driver, { manageMediaKeys: false });
|
||||||
|
driver.addPeer(peer);
|
||||||
|
await callParticipation.join(receiveOnly(), joinParams, openSlot);
|
||||||
|
driver.peerJoins(peer);
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(driver.calls("toDevice")).toEqual([]);
|
||||||
|
await callParticipation.leave();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a lost homeserver as a critical impairment in the status", async () => {
|
||||||
|
const driver = new MockRtcMatrixDriver();
|
||||||
|
const callParticipation = create(driver);
|
||||||
|
await callParticipation.join(receiveOnly(), joinParams, openSlot);
|
||||||
|
driver.setHomeserverConnected(false);
|
||||||
|
await waitFor("impairment in status$", () => {
|
||||||
|
const status = callParticipation.status$.value;
|
||||||
|
return (
|
||||||
|
FfiStatus.Connected.instanceOf(status) &&
|
||||||
|
status.inner.impairments[0]?.tag === "HomeserverUnreachable"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
driver.setHomeserverConnected(true);
|
||||||
|
await waitFor("impairment cleared", () => {
|
||||||
|
const status = callParticipation.status$.value;
|
||||||
|
return (
|
||||||
|
FfiStatus.Connected.instanceOf(status) &&
|
||||||
|
status.inner.impairments.length === 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await callParticipation.leave();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves and destroys the manager when the scope ends", async () => {
|
||||||
|
const driver = new MockRtcMatrixDriver();
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const callParticipation = create(driver, {}, scope);
|
||||||
|
await callParticipation.join(receiveOnly(), joinParams, openSlot);
|
||||||
|
scope.end();
|
||||||
|
await waitFor(
|
||||||
|
"leave sent",
|
||||||
|
() =>
|
||||||
|
driver
|
||||||
|
.calls("stickyEvent")
|
||||||
|
.some(
|
||||||
|
(c) =>
|
||||||
|
c.content.member === undefined ||
|
||||||
|
c.content.msc4354_sticky_key !== undefined,
|
||||||
|
) && driver.calls("cancelDelayed").length === 1,
|
||||||
|
);
|
||||||
|
expect(callParticipation.debugSnapshot()).toBe("{}");
|
||||||
|
await expect(
|
||||||
|
callParticipation.join(receiveOnly(), joinParams, openSlot),
|
||||||
|
).rejects.toThrow("ended");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns terminal causes into Element Call errors", async () => {
|
||||||
|
const driver = new MockRtcMatrixDriver({ transports: [] });
|
||||||
|
const callParticipation = create(driver);
|
||||||
|
await expect(
|
||||||
|
callParticipation.join(publishOnLivekit(), joinParams, openSlot),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(
|
||||||
|
errorForStatus(callParticipation.status$.value, {
|
||||||
|
domain: "example.org",
|
||||||
|
stickyEventsSupported: true,
|
||||||
|
}),
|
||||||
|
).toBeInstanceOf(MatrixRTCTransportMissingError);
|
||||||
|
|
||||||
|
const noSticky = new MockRtcMatrixDriver();
|
||||||
|
noSticky.refuseStickyEvents = true;
|
||||||
|
const p2 = create(noSticky);
|
||||||
|
await expect(
|
||||||
|
p2.join(receiveOnly(), joinParams, openSlot),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(
|
||||||
|
errorForStatus(p2.status$.value, {
|
||||||
|
domain: "example.org",
|
||||||
|
stickyEventsSupported: false,
|
||||||
|
}),
|
||||||
|
).toBeInstanceOf(StickyEventsRequiredError);
|
||||||
|
|
||||||
|
const fine = create(new MockRtcMatrixDriver());
|
||||||
|
expect(
|
||||||
|
errorForStatus(fine.status$.value, {
|
||||||
|
domain: "example.org",
|
||||||
|
stickyEventsSupported: true,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the crate's parameters from Element Call's config", () => {
|
||||||
|
expect(compatForMode(MatrixRTCMode.Compatibility)).toBe(
|
||||||
|
FfiElementCallCompat.StateEvents,
|
||||||
|
);
|
||||||
|
expect(compatForMode(MatrixRTCMode.Matrix_2_0)).toBe(
|
||||||
|
FfiElementCallCompat.StickyEvents,
|
||||||
|
);
|
||||||
|
expect(joinParams).toEqual({
|
||||||
|
applicationType: "m.call",
|
||||||
|
intent: undefined,
|
||||||
|
// js-sdk's 4 h default, capped at the sticky hour
|
||||||
|
stickyDurationMs: 3_600_000n,
|
||||||
|
keepAliveTimeoutMs: 15_000n,
|
||||||
|
degradedLifetimeMs: undefined,
|
||||||
|
delegateDelayedLeave: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
joinParamsFromConfig({
|
||||||
|
session: { ...session, membership_event_expiry_ms: 60_000 },
|
||||||
|
callIntent: "video",
|
||||||
|
delegateDelayedLeave: true,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
intent: "video",
|
||||||
|
stickyDurationMs: 60_000n,
|
||||||
|
delegateDelayedLeave: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
participationConfig({
|
||||||
|
mode: MatrixRTCMode.Compatibility,
|
||||||
|
manageMediaKeys: false,
|
||||||
|
session,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
compat: FfiElementCallCompat.StateEvents,
|
||||||
|
manageMediaKeys: false,
|
||||||
|
requireCrossSignedSender: false,
|
||||||
|
useKeyDelayMs: 50n,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
type Observable,
|
||||||
|
Subject,
|
||||||
|
combineLatest,
|
||||||
|
map,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { type RtcMatrixDriver } from "../../driver/RtcMatrixDriver";
|
||||||
|
import {
|
||||||
|
FfiMatrixDriver,
|
||||||
|
FfiMembershipState,
|
||||||
|
FfiParticipationManager,
|
||||||
|
FfiStatus,
|
||||||
|
type FfiConnectionWithMembers,
|
||||||
|
type FfiJoinParams,
|
||||||
|
type FfiLivekitToken,
|
||||||
|
type FfiLivekitTokenRequest,
|
||||||
|
type FfiMediaKey,
|
||||||
|
type FfiMembership,
|
||||||
|
type FfiParticipationConfig,
|
||||||
|
type FfiRtcTransport,
|
||||||
|
type FfiSendEventResponse,
|
||||||
|
type FfiSessionSnapshot,
|
||||||
|
type FfiToDeviceDelivery,
|
||||||
|
type FfiToDeviceRecipient,
|
||||||
|
type FfiTransportIntent,
|
||||||
|
type ConnectivitySinkLike,
|
||||||
|
type RoomEventSinkLike,
|
||||||
|
type StateUpdateSinkLike,
|
||||||
|
type ToDeviceSinkLike,
|
||||||
|
} from "../../matrix-rtc-sdk";
|
||||||
|
import { type Behavior } from "../Behavior";
|
||||||
|
import { Epoch, type ObservableScope, trackEpoch } from "../ObservableScope";
|
||||||
|
import { NoOpenSlotError } from "../../utils/errors";
|
||||||
|
import { ELEMENT_CALL_APPLICATION, ELEMENT_CALL_SLOT_ID } from "./slot";
|
||||||
|
import { LIVEKIT_TRANSPORT_TYPE } from "./transportIntent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to do about the room's slot when joining. A call needs an open
|
||||||
|
* MatrixRTC slot (`m.rtc.slot` state); a room that never had a call has
|
||||||
|
* none, and the client that starts the call opens it — if its user has the
|
||||||
|
* power level to send the state event.
|
||||||
|
*/
|
||||||
|
export interface SlotPolicy {
|
||||||
|
/** Whether the slot, if we open it, prescribes per-member media encryption (an encrypted room). */
|
||||||
|
encrypted: boolean;
|
||||||
|
/** Whether this user may send the slot state event (`RoomInfo.canOpenSlot`). */
|
||||||
|
canOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long to wait for the seed, and for our own slot event to echo back. */
|
||||||
|
const SLOT_WAIT_MS = 15_000;
|
||||||
|
|
||||||
|
export interface CallParticipationOptions {
|
||||||
|
/** One manager per `(room, slot)`; Element Call has one slot per room. */
|
||||||
|
slotId?: string;
|
||||||
|
config: FfiParticipationConfig;
|
||||||
|
/**
|
||||||
|
* A LiveKit service URL to fall back on when the homeserver advertises no
|
||||||
|
* transport (or cannot be asked) — Element Call's `config.json` value,
|
||||||
|
* which is Element Call's business rather than the host's.
|
||||||
|
*/
|
||||||
|
transportFallbackUrl?: string;
|
||||||
|
logger?: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element Call's view of one participation in a MatrixRTC session: the
|
||||||
|
* crate's `FfiParticipationManager` as behaviors. "Participation" is the
|
||||||
|
* crate's word for the FFI side; this is the RxJS wrapper a call is built on.
|
||||||
|
*
|
||||||
|
* The crate does everything Matrix: it projects the session from the
|
||||||
|
* driver's events, publishes and keeps alive our own membership, mints
|
||||||
|
* transport tokens and exchanges media keys. This class owns the manager
|
||||||
|
* for the scope's lifetime, seeds each behavior from the manager's getter and
|
||||||
|
* keeps it current from the manager's listener, and ends the participation
|
||||||
|
* (leaving if still joined) when the scope ends.
|
||||||
|
*/
|
||||||
|
export class CallParticipation {
|
||||||
|
private readonly logger: Logger;
|
||||||
|
private readonly matrixDriver: FfiMatrixDriver;
|
||||||
|
private readonly manager: FfiParticipationManager;
|
||||||
|
private ended = false;
|
||||||
|
|
||||||
|
private readonly membershipsSubject$: BehaviorSubject<FfiMembership[]>;
|
||||||
|
private readonly connectionsSubject$: BehaviorSubject<
|
||||||
|
FfiConnectionWithMembers[]
|
||||||
|
>;
|
||||||
|
private readonly keyMapSubject$: BehaviorSubject<FfiMediaKey[]>;
|
||||||
|
private readonly keyChangesSubject$ = new Subject<FfiMediaKey>();
|
||||||
|
private readonly statusSubject$: BehaviorSubject<FfiStatus>;
|
||||||
|
private readonly sessionSubject$: BehaviorSubject<FfiSessionSnapshot>;
|
||||||
|
private readonly ownMemberIdSubject$: BehaviorSubject<string | null>;
|
||||||
|
private readonly ownTransportIdentitySubject$: BehaviorSubject<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry per joined member, ourselves included once our own membership
|
||||||
|
* has echoed back from the homeserver. Members that left but may still hold
|
||||||
|
* our media key (`LeftWithKeys`) are not listed.
|
||||||
|
*/
|
||||||
|
public readonly memberships$: Behavior<Epoch<FfiMembership[]>>;
|
||||||
|
/** The LiveKit rooms to hold, with the token for each. */
|
||||||
|
public readonly connections$: Behavior<FfiConnectionWithMembers[]>;
|
||||||
|
/** Every media key in use, ours and theirs, one per (member, index). */
|
||||||
|
public readonly keyMap$: Behavior<FfiMediaKey[]>;
|
||||||
|
/** The single key that changed, as it changes. */
|
||||||
|
public readonly keyChanges$: Observable<FfiMediaKey> =
|
||||||
|
this.keyChangesSubject$;
|
||||||
|
public readonly status$: Behavior<FfiStatus>;
|
||||||
|
/** Slot open?, encrypted?, member count, seed honesty. */
|
||||||
|
public readonly session$: Behavior<FfiSessionSnapshot>;
|
||||||
|
/** Our member id, from the moment `join()` starts; null while not joined. */
|
||||||
|
public readonly ownMemberId$: Behavior<string | null>;
|
||||||
|
/** Our LiveKit participant identity, known as early as the member id. */
|
||||||
|
public readonly ownTransportIdentity$: Behavior<string | null>;
|
||||||
|
/** Our own entry in `memberships$`, once echoed. */
|
||||||
|
public readonly ownMembership$: Behavior<FfiMembership | null>;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
scope: ObservableScope,
|
||||||
|
driver: RtcMatrixDriver,
|
||||||
|
roomId: string,
|
||||||
|
userId: string,
|
||||||
|
deviceId: string,
|
||||||
|
options: CallParticipationOptions,
|
||||||
|
) {
|
||||||
|
this.logger = (options.logger ?? rootLogger).getChild(
|
||||||
|
"[CallParticipation]",
|
||||||
|
);
|
||||||
|
const rtcDriver =
|
||||||
|
options.transportFallbackUrl === undefined
|
||||||
|
? driver
|
||||||
|
: new TransportFallbackDriver(
|
||||||
|
driver,
|
||||||
|
options.transportFallbackUrl,
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
|
this.matrixDriver = new FfiMatrixDriver(rtcDriver);
|
||||||
|
this.manager = new FfiParticipationManager(
|
||||||
|
roomId,
|
||||||
|
options.slotId ?? ELEMENT_CALL_SLOT_ID,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
this.matrixDriver,
|
||||||
|
options.config,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.membershipsSubject$ = new BehaviorSubject(
|
||||||
|
joinedOnly(this.manager.memberships()),
|
||||||
|
);
|
||||||
|
this.connectionsSubject$ = new BehaviorSubject(this.manager.connections());
|
||||||
|
this.keyMapSubject$ = new BehaviorSubject(this.manager.keyMap());
|
||||||
|
this.statusSubject$ = new BehaviorSubject(this.manager.status());
|
||||||
|
this.sessionSubject$ = new BehaviorSubject(this.manager.session());
|
||||||
|
this.ownMemberIdSubject$ = new BehaviorSubject(
|
||||||
|
this.manager.ownMemberId() ?? null,
|
||||||
|
);
|
||||||
|
this.ownTransportIdentitySubject$ = new BehaviorSubject(
|
||||||
|
this.manager.ownTransportIdentity() ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.manager.setMembershipsListener({
|
||||||
|
onMembershipsChange: (memberships) => {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.membershipsSubject$.next(joinedOnly(memberships));
|
||||||
|
this.sessionSubject$.next(this.manager.session());
|
||||||
|
this.refreshOwnIdentity();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.manager.setConnectionsListener({
|
||||||
|
onConnectionsChange: (connections) => {
|
||||||
|
if (!this.ended) this.connectionsSubject$.next(connections);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.manager.setKeyMapListener({
|
||||||
|
onKeyMapChange: (keyMap, change) => {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.keyMapSubject$.next(keyMap);
|
||||||
|
this.keyChangesSubject$.next(change);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.manager.setStatusListener({
|
||||||
|
onStatusChange: (status) => {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.statusSubject$.next(status);
|
||||||
|
this.sessionSubject$.next(this.manager.session());
|
||||||
|
this.refreshOwnIdentity();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.manager.setKeyRejectedListener({
|
||||||
|
onKeyRejected: (memberId, reason) =>
|
||||||
|
this.logger.warn(`Discarded a media key from ${memberId}: ${reason}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.memberships$ = scope.behavior(
|
||||||
|
this.membershipsSubject$.pipe(trackEpoch()),
|
||||||
|
new Epoch(this.membershipsSubject$.value),
|
||||||
|
);
|
||||||
|
this.connections$ = scope.behavior(this.connectionsSubject$);
|
||||||
|
this.keyMap$ = scope.behavior(this.keyMapSubject$);
|
||||||
|
this.status$ = scope.behavior(this.statusSubject$);
|
||||||
|
this.session$ = scope.behavior(this.sessionSubject$);
|
||||||
|
this.ownMemberId$ = scope.behavior(this.ownMemberIdSubject$);
|
||||||
|
this.ownTransportIdentity$ = scope.behavior(
|
||||||
|
this.ownTransportIdentitySubject$,
|
||||||
|
);
|
||||||
|
this.ownMembership$ = scope.behavior(
|
||||||
|
combineLatest([this.memberships$, this.ownMemberId$]).pipe(
|
||||||
|
map(
|
||||||
|
([memberships, ownMemberId]) =>
|
||||||
|
memberships.value.find((m) => m.member.memberId === ownMemberId) ??
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
scope.onEnd(() => void this.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join the session, opening the room's slot first when nobody has
|
||||||
|
* (`slot`). Resolves once our membership is published, or rejects: with
|
||||||
|
* {@link NoOpenSlotError} when there is no slot and we may not open one,
|
||||||
|
* otherwise with the crate's typed error. The status keeps reporting from
|
||||||
|
* there.
|
||||||
|
*/
|
||||||
|
public async join(
|
||||||
|
intent: FfiTransportIntent,
|
||||||
|
params: FfiJoinParams,
|
||||||
|
slot: SlotPolicy,
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.ended) throw new Error("The participation has ended");
|
||||||
|
try {
|
||||||
|
await this.ensureOpenSlot(slot);
|
||||||
|
await this.manager.join(intent, params);
|
||||||
|
} finally {
|
||||||
|
this.refreshAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureOpenSlot(slot: SlotPolicy): Promise<void> {
|
||||||
|
// The crate's own `join` waits for the seed too, but the slot check has
|
||||||
|
// to come first: a slot read that has not finished looks like no slot.
|
||||||
|
await this.waitUntil(
|
||||||
|
() => this.manager.session().seeded,
|
||||||
|
"the session seed",
|
||||||
|
);
|
||||||
|
if (this.manager.session().slotOpen === true) return;
|
||||||
|
if (!slot.canOpen) throw new NoOpenSlotError();
|
||||||
|
this.logger.info(
|
||||||
|
`No open slot in the room; opening ${ELEMENT_CALL_SLOT_ID} (encrypted: ${slot.encrypted})`,
|
||||||
|
);
|
||||||
|
await this.manager.openSlot(ELEMENT_CALL_APPLICATION, slot.encrypted);
|
||||||
|
// Open only once the homeserver has echoed the state back.
|
||||||
|
await this.waitUntil(
|
||||||
|
() => this.manager.session().slotOpen === true,
|
||||||
|
"our slot event to echo back",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitUntil(
|
||||||
|
condition: () => boolean,
|
||||||
|
what: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + SLOT_WAIT_MS;
|
||||||
|
while (!condition()) {
|
||||||
|
if (this.ended) throw new Error("The participation has ended");
|
||||||
|
if (Date.now() > deadline)
|
||||||
|
throw new Error(`Timed out waiting for ${what}`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave the session. A no-op when not joined. */
|
||||||
|
public async leave(code?: string, reason?: string): Promise<void> {
|
||||||
|
if (this.ended) return;
|
||||||
|
if (FfiStatus.Disconnected.instanceOf(this.manager.status())) return;
|
||||||
|
try {
|
||||||
|
await this.manager.leave(code, reason);
|
||||||
|
} finally {
|
||||||
|
this.refreshAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The crate's diagnostics dump, for rageshakes. Not a UI contract. */
|
||||||
|
public debugSnapshot(): string {
|
||||||
|
return this.ended ? "{}" : this.manager.debugSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshOwnIdentity(): void {
|
||||||
|
this.ownMemberIdSubject$.next(this.manager.ownMemberId() ?? null);
|
||||||
|
this.ownTransportIdentitySubject$.next(
|
||||||
|
this.manager.ownTransportIdentity() ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After a join or leave the getters are fresh before any listener fires. */
|
||||||
|
private refreshAll(): void {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.statusSubject$.next(this.manager.status());
|
||||||
|
this.membershipsSubject$.next(joinedOnly(this.manager.memberships()));
|
||||||
|
this.connectionsSubject$.next(this.manager.connections());
|
||||||
|
this.sessionSubject$.next(this.manager.session());
|
||||||
|
this.refreshOwnIdentity();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async end(): Promise<void> {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = true;
|
||||||
|
if (!FfiStatus.Disconnected.instanceOf(this.manager.status())) {
|
||||||
|
try {
|
||||||
|
await this.manager.leave(undefined, undefined);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn("Could not leave the session cleanly", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.keyChangesSubject$.complete();
|
||||||
|
this.manager.uniffiDestroy();
|
||||||
|
this.matrixDriver.uniffiDestroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinedOnly(memberships: FfiMembership[]): FfiMembership[] {
|
||||||
|
return memberships.filter((m) => m.state === FfiMembershipState.Joined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers transport discovery with a configured LiveKit service URL when the
|
||||||
|
* host's homeserver advertises none or cannot be asked — the precedence
|
||||||
|
* Element Call has always had (homeserver first, `config.json` second) —
|
||||||
|
* and delegates everything else to the host's driver untouched.
|
||||||
|
*/
|
||||||
|
class TransportFallbackDriver implements RtcMatrixDriver {
|
||||||
|
public constructor(
|
||||||
|
private readonly inner: RtcMatrixDriver,
|
||||||
|
private readonly fallbackUrl: string,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
|
||||||
|
let transports: FfiRtcTransport[] = [];
|
||||||
|
try {
|
||||||
|
transports = await this.inner.getRtcTransports();
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.info(
|
||||||
|
"Transport discovery failed; falling back to the configured LiveKit service",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (transports.some((t) => t.transportType === LIVEKIT_TRANSPORT_TYPE))
|
||||||
|
return transports;
|
||||||
|
this.logger.info(
|
||||||
|
"The homeserver advertises no LiveKit transport; using the configured one",
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
transportType: LIVEKIT_TRANSPORT_TYPE,
|
||||||
|
propertiesJson: JSON.stringify({
|
||||||
|
livekit_service_url: this.fallbackUrl,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendStickyEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
contentJson: string,
|
||||||
|
durationMs: bigint,
|
||||||
|
): Promise<FfiSendEventResponse> {
|
||||||
|
return this.inner.sendStickyEvent(
|
||||||
|
roomId,
|
||||||
|
eventType,
|
||||||
|
contentJson,
|
||||||
|
durationMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
public async sendStateEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string,
|
||||||
|
contentJson: string,
|
||||||
|
): Promise<FfiSendEventResponse> {
|
||||||
|
return this.inner.sendStateEvent(roomId, eventType, stateKey, contentJson);
|
||||||
|
}
|
||||||
|
public async sendDelayedEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
contentJson: string,
|
||||||
|
delayMs: bigint,
|
||||||
|
stickyDurationMs: bigint | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.inner.sendDelayedEvent(
|
||||||
|
roomId,
|
||||||
|
eventType,
|
||||||
|
contentJson,
|
||||||
|
delayMs,
|
||||||
|
stickyDurationMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
public async sendDelayedStateEvent(
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string,
|
||||||
|
contentJson: string,
|
||||||
|
delayMs: bigint,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.inner.sendDelayedStateEvent(
|
||||||
|
roomId,
|
||||||
|
eventType,
|
||||||
|
stateKey,
|
||||||
|
contentJson,
|
||||||
|
delayMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
public async restartDelayedEvent(
|
||||||
|
roomId: string,
|
||||||
|
delayId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.inner.restartDelayedEvent(roomId, delayId);
|
||||||
|
}
|
||||||
|
public async cancelDelayedEvent(
|
||||||
|
roomId: string,
|
||||||
|
delayId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.inner.cancelDelayedEvent(roomId, delayId);
|
||||||
|
}
|
||||||
|
public async delegateLivekitDelayedLeave(
|
||||||
|
roomId: string,
|
||||||
|
slotId: string,
|
||||||
|
memberJson: string,
|
||||||
|
delayId: string,
|
||||||
|
livekitServiceUrl: string | undefined,
|
||||||
|
delayMs: bigint,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.inner.delegateLivekitDelayedLeave(
|
||||||
|
roomId,
|
||||||
|
slotId,
|
||||||
|
memberJson,
|
||||||
|
delayId,
|
||||||
|
livekitServiceUrl,
|
||||||
|
delayMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
public async sendToDevice(
|
||||||
|
recipients: FfiToDeviceRecipient[],
|
||||||
|
eventType: string,
|
||||||
|
contentJson: string,
|
||||||
|
): Promise<FfiToDeviceDelivery[]> {
|
||||||
|
return this.inner.sendToDevice(recipients, eventType, contentJson);
|
||||||
|
}
|
||||||
|
public async getLivekitToken(
|
||||||
|
request: FfiLivekitTokenRequest,
|
||||||
|
): Promise<FfiLivekitToken> {
|
||||||
|
return this.inner.getLivekitToken(request);
|
||||||
|
}
|
||||||
|
public async readEvents(
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string | undefined,
|
||||||
|
limit: number,
|
||||||
|
): Promise<string[]> {
|
||||||
|
return this.inner.readEvents(eventType, stateKey, limit);
|
||||||
|
}
|
||||||
|
public async readState(
|
||||||
|
eventType: string,
|
||||||
|
stateKey: string | undefined,
|
||||||
|
): Promise<string[]> {
|
||||||
|
return this.inner.readState(eventType, stateKey);
|
||||||
|
}
|
||||||
|
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
|
||||||
|
this.inner.subscribeRoomEvents(sink);
|
||||||
|
}
|
||||||
|
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
|
||||||
|
this.inner.subscribeToDeviceEvents(sink);
|
||||||
|
}
|
||||||
|
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
|
||||||
|
this.inner.subscribeStateUpdates(sink);
|
||||||
|
}
|
||||||
|
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
|
||||||
|
this.inner.subscribeConnectivity(sink);
|
||||||
|
}
|
||||||
|
public isHomeserverConnected(): boolean {
|
||||||
|
return this.inner.isHomeserverConnected();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
FfiDisconnectCause,
|
||||||
|
FfiJoinError,
|
||||||
|
FfiStatus,
|
||||||
|
} from "../../matrix-rtc-sdk";
|
||||||
|
import {
|
||||||
|
ConnectionLostError,
|
||||||
|
type ElementCallError,
|
||||||
|
MatrixRTCTransportMissingError,
|
||||||
|
MembershipManagerError,
|
||||||
|
StickyEventsRequiredError,
|
||||||
|
} from "../../utils/errors";
|
||||||
|
|
||||||
|
export interface DisconnectContext {
|
||||||
|
/** The homeserver's domain, for the "no transport" message. */
|
||||||
|
domain: string;
|
||||||
|
/** Whether the homeserver was found to accept sticky events. */
|
||||||
|
stickyEventsSupported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The error a participation ended with, or null when it ended on purpose
|
||||||
|
* (never joined, or the host left).
|
||||||
|
*
|
||||||
|
* The crate splits failures into recoverable state (`impairments`, which the
|
||||||
|
* call UI shows as interruptions) and terminal causes; only the latter are
|
||||||
|
* errors here.
|
||||||
|
*/
|
||||||
|
export function errorForStatus(
|
||||||
|
status: FfiStatus,
|
||||||
|
context: DisconnectContext,
|
||||||
|
): ElementCallError | null {
|
||||||
|
if (!FfiStatus.Disconnected.instanceOf(status)) return null;
|
||||||
|
const cause = status.inner.cause;
|
||||||
|
if (
|
||||||
|
FfiDisconnectCause.NeverJoined.instanceOf(cause) ||
|
||||||
|
FfiDisconnectCause.LeftByHost.instanceOf(cause)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
if (FfiDisconnectCause.JoinFailed.instanceOf(cause)) {
|
||||||
|
const error = cause.inner.error;
|
||||||
|
if (FfiJoinError.NoTransport.instanceOf(error))
|
||||||
|
return new MatrixRTCTransportMissingError(context.domain);
|
||||||
|
// A homeserver that refuses sticky events fails the very first send.
|
||||||
|
if (FfiJoinError.Driver.instanceOf(error) && !context.stickyEventsSupported)
|
||||||
|
return new StickyEventsRequiredError();
|
||||||
|
return new MembershipManagerError(new Error(describeJoinError(error)));
|
||||||
|
}
|
||||||
|
// SlotClosed, ManagerStopped: we are out and nothing will bring us back.
|
||||||
|
return new ConnectionLostError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeJoinError(error: FfiJoinError): string {
|
||||||
|
const inner: unknown = "inner" in error ? error.inner : undefined;
|
||||||
|
const message =
|
||||||
|
typeof inner === "object" && inner !== null && "message" in inner
|
||||||
|
? inner.message
|
||||||
|
: undefined;
|
||||||
|
return typeof message === "string" ? `${error.tag}: ${message}` : error.tag;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ResolvedConfigOptions,
|
||||||
|
MatrixRTCMode,
|
||||||
|
} from "../../config/ConfigOptions";
|
||||||
|
import {
|
||||||
|
FfiElementCallCompat,
|
||||||
|
type FfiJoinParams,
|
||||||
|
type FfiParticipationConfig,
|
||||||
|
} from "../../matrix-rtc-sdk";
|
||||||
|
import { ELEMENT_CALL_APPLICATION } from "./slot";
|
||||||
|
|
||||||
|
/** matrix-js-sdk caps a sticky membership at an hour; so does the crate. */
|
||||||
|
const MAX_STICKY_DURATION_MS = 60 * 60 * 1000;
|
||||||
|
/** matrix-js-sdk's default membership expiry when the config names none. */
|
||||||
|
const DEFAULT_MEMBERSHIP_EXPIRY_MS = 4 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which pre-2026 Element Call dialect the crate speaks for a given mode.
|
||||||
|
* `compatibility` is MSC3401 state events; `matrix_2_0` is the sticky
|
||||||
|
* dialect deployed Element Call clients read today. Spec MSC4143 (`Off`)
|
||||||
|
* waits until Element Call opens slots.
|
||||||
|
*/
|
||||||
|
export function compatForMode(mode: MatrixRTCMode): FfiElementCallCompat {
|
||||||
|
switch (mode) {
|
||||||
|
case MatrixRTCMode.Compatibility:
|
||||||
|
return FfiElementCallCompat.StateEvents;
|
||||||
|
case MatrixRTCMode.Matrix_2_0:
|
||||||
|
return FfiElementCallCompat.StickyEvents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParticipationConfigInputs {
|
||||||
|
mode: MatrixRTCMode;
|
||||||
|
/** Whether this call encrypts media with per-participant keys. */
|
||||||
|
manageMediaKeys: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to discard media keys from senders not reported as cross-signed
|
||||||
|
* (MSC4153). Off for parity with matrix-js-sdk, which never checked, and
|
||||||
|
* because a passwordless guest cannot be cross-signed.
|
||||||
|
*
|
||||||
|
* TODO: we want this on. Turning it on needs (1) the SPA's passwordless
|
||||||
|
* users to be cross-signed or given another way in, and (2) the crate to
|
||||||
|
* report the sender's verdict on the tile while the check is off
|
||||||
|
* (`FfiMediaKeyState.senderCrossSigned`, plan item C10), so the UI can
|
||||||
|
* show an unverified sender before the switch flips.
|
||||||
|
*/
|
||||||
|
requireCrossSignedSender?: boolean;
|
||||||
|
session: ResolvedConfigOptions["matrix_rtc_session"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function participationConfig({
|
||||||
|
mode,
|
||||||
|
manageMediaKeys,
|
||||||
|
requireCrossSignedSender = false,
|
||||||
|
session,
|
||||||
|
}: ParticipationConfigInputs): FfiParticipationConfig {
|
||||||
|
return {
|
||||||
|
compat: compatForMode(mode),
|
||||||
|
manageMediaKeys,
|
||||||
|
requireCrossSignedSender,
|
||||||
|
useKeyDelayMs: BigInt(session.wait_for_key_rotation_ms ?? 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinParamsInputs {
|
||||||
|
session: ResolvedConfigOptions["matrix_rtc_session"];
|
||||||
|
/** `m.call.intent`: what kind of call the user is starting. */
|
||||||
|
callIntent?: string;
|
||||||
|
/** Hand the delayed leave to the SFU (MSC4195) — only where the probe said it can. */
|
||||||
|
delegateDelayedLeave: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The crate's join parameters from Element Call's `matrix_rtc_session`
|
||||||
|
* config. Keys the crate has no knob for (`restart_ms`, `restart_timeout_ms`,
|
||||||
|
* `network_error_retry_ms`, `key_rotation_participant_limit`,
|
||||||
|
* `delegated_delayed_leave.*`) have no effect any more.
|
||||||
|
*/
|
||||||
|
export function joinParamsFromConfig({
|
||||||
|
session,
|
||||||
|
callIntent,
|
||||||
|
delegateDelayedLeave,
|
||||||
|
}: JoinParamsInputs): FfiJoinParams {
|
||||||
|
return {
|
||||||
|
applicationType: ELEMENT_CALL_APPLICATION,
|
||||||
|
intent: callIntent,
|
||||||
|
stickyDurationMs: BigInt(
|
||||||
|
Math.min(
|
||||||
|
session.membership_event_expiry_ms ?? DEFAULT_MEMBERSHIP_EXPIRY_MS,
|
||||||
|
MAX_STICKY_DURATION_MS,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
keepAliveTimeoutMs: BigInt(session.delayed_leave.delay_ms),
|
||||||
|
degradedLifetimeMs: undefined,
|
||||||
|
delegateDelayedLeave,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MatrixRTC slot Element Call lives in, `{application}#{id}` per
|
||||||
|
* MSC4143. One per room: the application is a call and the id is the room's.
|
||||||
|
*/
|
||||||
|
export const ELEMENT_CALL_SLOT_ID = "m.call#ROOM";
|
||||||
|
export const ELEMENT_CALL_APPLICATION = "m.call";
|
||||||
|
/**
|
||||||
|
* The state event type the crate opens a slot with (the unstable spelling
|
||||||
|
* deployed homeservers know). Opening a slot needs the power level to send
|
||||||
|
* it, which is what `RoomInfo.canOpenSlot` answers.
|
||||||
|
*/
|
||||||
|
export const ELEMENT_CALL_SLOT_EVENT_TYPE = "org.matrix.msc4143.rtc.slot";
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FfiTransportIntent } from "../../matrix-rtc-sdk";
|
||||||
|
|
||||||
|
export const LIVEKIT_TRANSPORT_TYPE = "livekit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish on LiveKit. With a `serviceUrl` (a developer's custom URL) that
|
||||||
|
* transport is used as given; without one the crate discovers it through the
|
||||||
|
* driver's `getRtcTransports`, which is where the homeserver's answer and
|
||||||
|
* Element Call's config fallback come in.
|
||||||
|
*/
|
||||||
|
export function publishOnLivekit(serviceUrl?: string): FfiTransportIntent {
|
||||||
|
return new FfiTransportIntent.Publish({
|
||||||
|
transport: {
|
||||||
|
transportType: LIVEKIT_TRANSPORT_TYPE,
|
||||||
|
propertiesJson: JSON.stringify(
|
||||||
|
serviceUrl === undefined ? {} : { livekit_service_url: serviceUrl },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take part without publishing media: recorders, observers, tests. */
|
||||||
|
export function receiveOnly(): FfiTransportIntent {
|
||||||
|
return new FfiTransportIntent.ReceiveOnly({
|
||||||
|
canSubscribe: [LIVEKIT_TRANSPORT_TYPE],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ export enum ErrorCode {
|
|||||||
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
|
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
|
||||||
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
||||||
STICKY_EVENTS_NOT_SUPPORTED = "STICKY_EVENTS_NOT_SUPPORTED",
|
STICKY_EVENTS_NOT_SUPPORTED = "STICKY_EVENTS_NOT_SUPPORTED",
|
||||||
|
/** The room has no open MatrixRTC slot and the user may not open one. */
|
||||||
|
NO_OPEN_SLOT = "NO_OPEN_SLOT",
|
||||||
OPEN_ID_ERROR = "OPEN_ID_ERROR",
|
OPEN_ID_ERROR = "OPEN_ID_ERROR",
|
||||||
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
|
NO_MATRIX_2_AUTHORIZATION_SERVICE = "NO_MATRIX_2_0_AUTHORIZATION_SERVICE",
|
||||||
SFU_ERROR = "SFU_ERROR",
|
SFU_ERROR = "SFU_ERROR",
|
||||||
@@ -140,6 +142,20 @@ export class StickyEventsRequiredError extends ElementCallError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* A call needs an open MatrixRTC slot in the room. Nobody has opened one, and
|
||||||
|
* this user lacks the power level to send the slot state event.
|
||||||
|
*/
|
||||||
|
export class NoOpenSlotError extends ElementCallError {
|
||||||
|
public constructor() {
|
||||||
|
super(
|
||||||
|
i18n.t("error.no_open_slot"),
|
||||||
|
ErrorCode.NO_OPEN_SLOT,
|
||||||
|
ErrorCategory.CONFIGURATION_ISSUE,
|
||||||
|
i18n.t("error.no_open_slot_description"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error indicating that end-to-end encryption is not supported in the current environment.
|
* Error indicating that end-to-end encryption is not supported in the current environment.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { initMatrixRtcSdk } from "../matrix-rtc-sdk";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the MatrixRTC SDK for a unit test. The browser builds fetch the wasm
|
||||||
|
* by URL; under vitest there is no server, so the bytes come from disk.
|
||||||
|
*
|
||||||
|
* Only suites that construct a participation manager need this; do not put
|
||||||
|
* it in the global setup, where every test file would pay for the boot.
|
||||||
|
*/
|
||||||
|
export async function initMatrixRtcSdkForTests(): Promise<void> {
|
||||||
|
// Relative to this file: under the jsdom environment `import.meta.url` is
|
||||||
|
// not a file URL and `process.cwd()` is not the repository, but vitest
|
||||||
|
// still provides `__dirname`.
|
||||||
|
const wasm = readFileSync(
|
||||||
|
resolve(
|
||||||
|
__dirname,
|
||||||
|
"../matrix-rtc-sdk/generated/wasm-bindgen/index_bg.wasm",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await initMatrixRtcSdk(wasm);
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ export default defineConfig((configEnv) =>
|
|||||||
"src/utils/test.ts",
|
"src/utils/test.ts",
|
||||||
"src/utils/test-viewmodel.ts",
|
"src/utils/test-viewmodel.ts",
|
||||||
"src/utils/test-fixtures.ts",
|
"src/utils/test-fixtures.ts",
|
||||||
|
"src/utils/test-matrix-rtc.ts",
|
||||||
|
"src/matrix-rtc-sdk/generated/**",
|
||||||
"playwright/**",
|
"playwright/**",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user