mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Add rust based createCallViewModel
This commit is contained in:
@@ -18,7 +18,7 @@ import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
ElementCall,
|
||||
ElementCallClientBased,
|
||||
type ElementCallHandle,
|
||||
supportedLanguages,
|
||||
} from "../index";
|
||||
@@ -160,7 +160,7 @@ const Pane: FC<{
|
||||
one of the things we cannot find out from the standalone app */}
|
||||
<div className={styles.paneCall} data-testid="call-container">
|
||||
{mounted && (
|
||||
<ElementCall
|
||||
<ElementCallClientBased
|
||||
ref={handle}
|
||||
client={session.client}
|
||||
roomId={roomId}
|
||||
|
||||
+108
-14
@@ -78,6 +78,10 @@ import { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
import { useTheme } from "../src/useTheme";
|
||||
import { useStableValue } from "../src/useStableValue";
|
||||
import { type RtcMatrixDriver } from "../src/driver/RtcMatrixDriver";
|
||||
import { type ElementCallMatrixClientDriver } from "../src/driver/ElementCallMatrixClientDriver";
|
||||
import { JsSdkRtcMatrixDriver } from "../src/driver/jsSdk/JsSdkRtcMatrixDriver";
|
||||
import { JsSdkElementCallMatrixClientDriver } from "../src/driver/jsSdk/JsSdkElementCallMatrixClientDriver";
|
||||
import styles from "./ElementCall.module.css";
|
||||
import {
|
||||
type ElementCallHandle,
|
||||
@@ -89,6 +93,20 @@ import { supportedLanguages, translationsBackend } from "./localization";
|
||||
// The languages Element Call can be shown in
|
||||
export { supportedLanguages } from "./localization";
|
||||
|
||||
// What a host implements to drive Element Call with its own Matrix stack, and
|
||||
// the implementations of both over a matrix-js-sdk client
|
||||
export { type RtcMatrixDriver } from "../src/driver/RtcMatrixDriver";
|
||||
export {
|
||||
type ElementCallMatrixClientDriver,
|
||||
type DriverCapabilities,
|
||||
type RoomInfo,
|
||||
type RoomMemberProfile,
|
||||
type TimelineEvent,
|
||||
type OwnProfile,
|
||||
} from "../src/driver/ElementCallMatrixClientDriver";
|
||||
export { JsSdkRtcMatrixDriver } from "../src/driver/jsSdk/JsSdkRtcMatrixDriver";
|
||||
export { JsSdkElementCallMatrixClientDriver } from "../src/driver/jsSdk/JsSdkElementCallMatrixClientDriver";
|
||||
|
||||
// How the host and Element Call talk to each other, and what they say
|
||||
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
|
||||
export {
|
||||
@@ -124,12 +142,25 @@ export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||
|
||||
export interface ElementCallProps {
|
||||
/**
|
||||
* The client to place the call with. Element Call does not authenticate
|
||||
* anyone or manage a session of its own; this one is the host's.
|
||||
* The MatrixRTC side of the host's Matrix stack, bound to the room to call
|
||||
* in: what the crate needs to publish a membership, exchange keys and mint
|
||||
* transport tokens. Element Call does not authenticate anyone or manage a
|
||||
* session of its own; both drivers are the host's.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
rtcDriver: RtcMatrixDriver;
|
||||
/**
|
||||
* Everything else Element Call asks of a Matrix client for that room: who
|
||||
* is in it, its name and avatar, the timeline for reactions and
|
||||
* notifications, the user's own profile.
|
||||
*/
|
||||
clientDriver: ElementCallMatrixClientDriver;
|
||||
/**
|
||||
* The room to call in. Optional, and when given it must be the room the
|
||||
* drivers are bound to (`clientDriver.roomId`). Kept for one release so
|
||||
* that hosts written for the client-based component can move over one
|
||||
* prop at a time; then it goes.
|
||||
*/
|
||||
roomId?: string;
|
||||
/**
|
||||
* What the user asked for — whether they started the call or joined one that
|
||||
* was already running, and whether it is a call in a group or a DM. Element
|
||||
@@ -176,6 +207,24 @@ export interface ElementCallProps {
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link ElementCall} for a host with a matrix-js-sdk client: the two drivers
|
||||
* are built here from the client and the room, so the host hands over the
|
||||
* client as it always has.
|
||||
*/
|
||||
export type ElementCallClientBasedProps = Omit<
|
||||
ElementCallProps,
|
||||
"rtcDriver" | "clientDriver" | "roomId"
|
||||
> & {
|
||||
/**
|
||||
* The client to place the call with. Element Call does not authenticate
|
||||
* anyone or manage a session of its own; this one is the host's.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares the things Element Call needs before it can be shown: translations,
|
||||
* `Intl` polyfills for older browsers, and its configuration.
|
||||
@@ -230,8 +279,9 @@ const Decoration: FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
};
|
||||
|
||||
export const ElementCall: FC<ElementCallProps> = ({
|
||||
client,
|
||||
roomId,
|
||||
rtcDriver,
|
||||
clientDriver,
|
||||
roomId: suppliedRoomId,
|
||||
intent = UserIntent.JoinExistingCall,
|
||||
config,
|
||||
hostBridge: suppliedHostBridge,
|
||||
@@ -240,6 +290,11 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
language,
|
||||
}): ReactNode => {
|
||||
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
|
||||
const roomId = clientDriver.roomId;
|
||||
if (suppliedRoomId !== undefined && suppliedRoomId !== roomId)
|
||||
throw new Error(
|
||||
`Element Call was asked to call in ${suppliedRoomId} with drivers bound to ${roomId}`,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== undefined)
|
||||
@@ -288,17 +343,24 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
};
|
||||
}, [controlledAudioDevices, callIntent]);
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
// Until the React tree below reads the drivers (plan slice S4), it runs on
|
||||
// matrix-js-sdk directly, so for now the drivers have to be the matrix-js-sdk
|
||||
// ones and the client is taken back out of them. `ElementCallClientBased`
|
||||
// is the way to get here in the meantime; a host with drivers of its own
|
||||
// cannot be served yet.
|
||||
if (!(clientDriver instanceof JsSdkElementCallMatrixClientDriver))
|
||||
throw new Error(
|
||||
"Element Call cannot yet run on drivers other than the matrix-js-sdk ones; use ElementCallClientBased",
|
||||
);
|
||||
// `rtcDriver` is what the call will run on once the tree reads it; for now
|
||||
// it is only checked to be there.
|
||||
void rtcDriver;
|
||||
const { client, room } = clientDriver;
|
||||
const rtcSession = useMemo(
|
||||
() => (room === null ? null : client.matrixRTC.getRoomSession(room)),
|
||||
() => client.matrixRTC.getRoomSession(room),
|
||||
[client, room],
|
||||
);
|
||||
|
||||
if (rtcSession === null)
|
||||
logger.error(
|
||||
`Element Call was asked to call in ${roomId}, which its host's client does not know about`,
|
||||
);
|
||||
|
||||
// Everything the call needs is in hand once these exist, and the first
|
||||
// render with them is where the call itself appears: the moment the host
|
||||
// is told that Element Call has loaded, as the widget tells its client once
|
||||
@@ -356,3 +418,35 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const ElementCallClientBased: FC<ElementCallClientBasedProps> = ({
|
||||
client,
|
||||
roomId,
|
||||
...props
|
||||
}): ReactNode => {
|
||||
const room = client.getRoom(roomId);
|
||||
const drivers = useMemo(
|
||||
() =>
|
||||
room === null
|
||||
? null
|
||||
: {
|
||||
rtcDriver: new JsSdkRtcMatrixDriver(client, room),
|
||||
clientDriver: new JsSdkElementCallMatrixClientDriver(client, room),
|
||||
},
|
||||
[client, room],
|
||||
);
|
||||
// The RTC driver hooks client listeners for the crate's sinks; let go of
|
||||
// them with the drivers.
|
||||
useEffect(() => {
|
||||
if (drivers === null) return;
|
||||
return (): void => drivers.rtcDriver.detach();
|
||||
}, [drivers]);
|
||||
|
||||
if (drivers === null) {
|
||||
logger.error(
|
||||
`Element Call was asked to call in ${roomId}, which its host's client does not know about`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return <ElementCall {...props} {...drivers} />;
|
||||
};
|
||||
|
||||
+125
-46
@@ -16,8 +16,10 @@ Status legend: ☐ todo · ◐ in progress · ☑ done.
|
||||
**Where things stand (2026-09-14):** S0a, S0b, S1a, S1b and S2 are
|
||||
implemented and green (`pnpm lint`, `pnpm format:check`, `pnpm test:unit`:
|
||||
101 files / 785 tests). Nothing is committed yet, in either repository: the
|
||||
crate changes (C2–C8; C1 was reverted in favour of slot opening; C11 and C12 done;
|
||||
C9 and C10 are pending) sit uncommitted in
|
||||
crate changes C2–C12 (C1 reverted in favour of slot opening) are done; C2–C8,
|
||||
C11 and C12 are committed in the draft repo as `a8e21b3`, C5 (final form), C9
|
||||
and C10 are still uncommitted there. In Element Call everything sits
|
||||
uncommitted in
|
||||
`~/Projects/matrix-rust-rtc/MatrixSdkArchitectureDraft`, and Element Call's
|
||||
branch `toger5/oxidation` holds the vendored bindings, the driver layer
|
||||
(`src/driver/**`), the participation layer (`src/state/rtc/**`) and the
|
||||
@@ -136,8 +138,9 @@ membership, roster, encryption, impairments} | Leaving`.
|
||||
is closed, so a room without an `m.rtc.slot` has no call, in every dialect.
|
||||
`openSlot(application, encrypted)` / `closeSlot()` send the state event;
|
||||
the slot id `m.call#ROOM` matches js-sdk's default.
|
||||
- `FfiElementCallCompat.{Off, StickyEvents, StateEvents}` today; `StickyEvents`
|
||||
is removed by C9, leaving `Off` (spec MSC4143) and `StateEvents` (MSC3401).
|
||||
- `FfiElementCallCompat.{Off, StateEvents}`: spec MSC4143, or MSC3401 state
|
||||
events for the clients that predate sticky events (C9 removed the never
|
||||
deployed 2025 sticky dialect).
|
||||
- All `u64` fields are `bigint` in TypeScript; `Vec<u8>` is `ArrayBuffer`.
|
||||
- Listener callbacks arrive one timer tick after the emitting call (pumps
|
||||
sleep through `setTimeout`, `executor.rs:86`); getters are fresh.
|
||||
@@ -157,20 +160,21 @@ membership, roster, encryption, impairments} | Leaving`.
|
||||
|
||||
**Must change in the crate (S0a) — each blocks a later slice:**
|
||||
|
||||
| # | Problem | Change |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| C1 | A successful `read_state("m.rtc.slot")` returning `[]` marks slot state supplied and every slot other than the legacy `""` resolves `Closed` (`session/state.rs`); `join()` then fails with `SlotClosed` and every MSC4143 peer is excluded. Element Call never sent `m.rtc.slot`. | **Kept as the crate has it: no slot means no call.** Element Call opens the slot when nobody has (`CallParticipation.join` with a `SlotPolicy`: `openSlot("m.call", encrypted)`, then wait for the echo), which needs the power level to send `org.matrix.msc4143.rtc.slot`; without it the join fails with `NoOpenSlotError`. Existing rooms keep working because the first call in a room opens its slot. A compat-mode relaxation was tried and reverted. |
|
||||
| C2 | `manage_media_keys`, `require_cross_signed_sender`, `use_key_delay_ms` are not settable over the FFI; defaults are `true`, `true`, 1000 ms. | New record `FfiParticipationConfig { compat, manage_media_keys, require_cross_signed_sender, use_key_delay_ms }` as the constructor argument (replaces the bare `compat`). |
|
||||
| C3 | `StickyEvents` compat sent keys as `org.matrix.msc4143.rtc.encryption_key` while deployed clients read only `io.element.call.encryption_keys`. | Made moot by C9 and reverted with it: the sticky dialect goes away entirely, so `Off` sends the spec key message and `StateEvents` the legacy one, with no middle case. |
|
||||
| C4 | `FfiMember` has no membership `event_id`; reactions relate to it (§4.3). | `Member.event_id: Option<String>` threaded through `session/dispatch.rs` → `convert/*` → `state.rs` (currently dropped at `state.rs:402`) → `FfiMember.event_id`. |
|
||||
| C5 | Delegating the delayed leave (MSC4195) is split between the crate and the host: the draft's driver method makes the _adapter_ perform the whole delegation, its demo adapter calls a homeserver endpoint that 401s on a widget client, and Element Call today does it differently (probe, then the authorisation service's `get_token` with `delay_id`/`delay_timeout`/`delay_cs_api_url`). Nothing of this exists in the real `crates/` yet. | **The crate owns the policy; the driver keeps two primitives.** (a) `delegate_delayed_leave_via_homeserver(room, slot, member, delay_id)`: one authenticated POST to the CS API endpoint (`/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`, the spelling Element Call probes today; a widget client answers `Unsupported`). (b) `LivekitTokenRequest.delegation: Option<{ delay_id, delay_timeout_ms }>`: the adapter appends `delay_id`, `delay_timeout` and `delay_cs_api_url` (its own homeserver URL) to the `get_token` / `sfu/get` body it already sends. The own-membership machine tries (a) first and, on `Unsupported` or any failure, (b) against the transport we publish on, i.e. Element Call's OpenID → JWT → scheduled-event path; only if both fail does it keep restarting the switch itself. **Arm-after-confirm:** the short delayed leave stays armed through the join; delegation arms a second, ≥ 1 h delayed leave, delegates _that_, and cancels the short one on success (or the long one on failure), so no moment is left without an armed leave and a failed delegation costs nothing. `KeepAlive::Delegated` says which route succeeded. The interim C5 (service URL and delay on the request) and Element Call's driver-side probe and JWT delegation are replaced by this. |
|
||||
| C6 | The transport identity is a pure function (`connections/mod.rs:116-135`) but not exported; the own identity is needed before our membership echo to set our own media key. | Export `FfiParticipationManager.own_transport_identity(): Option<String>`. |
|
||||
| C7 | Doc comment on `FfiMembership.connections` says `ws_url`s. | Fix the comment. |
|
||||
| C8 | Both converters set `display_name` / `avatar_url` to `None`, so every host would re-derive them from room members. | The session records each `m.room.member` profile (whether or not the roster condition is enforced) and stamps it on members at projection time. |
|
||||
| C9 | `ElementCallCompat::StickyEvents` models the 2025 Element Call sticky dialect (`member: {user_id, device_id, id}`, flat `rtc_transports`, `versions`, legacy key message). No deployment uses it: sticky-event calls have not shipped. | **Remove the mode.** Delete `own_membership/compat_2025.rs`, the 2025 block in `session/convert/msc4143.rs` and its dispatch arm, the `StickyEvents` variants of `ElementCallCompat` / `FfiElementCallCompat`, and their tests and acceptance tests; `outbound_event_type` / `build_content` keep two arms (`Off`, `StateEvents`). Element Call then maps `Matrix_2_0 → Off`: spec MSC4143 sticky events with slots (which Element Call opens, C1) and the spec key message. |
|
||||
| C10 | `MediaKeyState` has `holds_our_key`, `have_their_key` and `rejection`, so a tile learns about an unsigned sender only when `require_cross_signed_sender` is on (the key is discarded, `rejection: NotCrossSigned`). With the check off the verdict is dropped on accept and the tile cannot show an unverified sender. | Keep the MSC4153 verdict of the accepted key per member and expose it as `FfiMediaKeyState.sender_cross_signed: Option<bool>` (`None` when the host could not tell). Element Call runs with the check off (§5.8) and wants to show the state on the tile until it is turned on. |
|
||||
| C11 | No way to change `application["m.call.intent"]` while joined; Element Call flips it between `audio` and `video` when the camera is toggled (`updateCallIntent`). | `update_application(intent)` on the own-membership manager, facade and FFI: while connected the membership is re-published at once on the refresh path (a failure retries like a refresh); during a join the join event carries it; refused with `NotJoined` otherwise. |
|
||||
| C12 | Homeserver connectivity lived only in Element Call's driver; the crate could not tell a dead homeserver from a quiet one, and a participation's status said nothing about it. | **Done.** `ConnectivityDriver` (`is_homeserver_connected`, `subscribe_connectivity`) joins the `MatrixDriver` sum; the FFI adds `ConnectivitySink`, the two callback methods and `FfiParticipationManager.is_homeserver_connected()`; the facade pump consumes the stream and reports `Impairment::HomeserverUnreachable { since_ts }` (Critical, sorted first) in every non-disconnected status until the driver reports the homeserver back. The web-test-app mock and js-sdk driver implement it. A matrix-rust-sdk adapter implements the same two methods later. |
|
||||
| # | Problem | Change |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| C1 | A successful `read_state("m.rtc.slot")` returning `[]` marks slot state supplied and every slot other than the legacy `""` resolves `Closed` (`session/state.rs`); `join()` then fails with `SlotClosed` and every MSC4143 peer is excluded. Element Call never sent `m.rtc.slot`. | **Kept as the crate has it: no slot means no call.** Element Call opens the slot when nobody has (`CallParticipation.join` with a `SlotPolicy`: `openSlot("m.call", encrypted)`, then wait for the echo), which needs the power level to send `org.matrix.msc4143.rtc.slot`; without it the join fails with `NoOpenSlotError`. Existing rooms keep working because the first call in a room opens its slot. A compat-mode relaxation was tried and reverted. |
|
||||
| C2 | `manage_media_keys`, `require_cross_signed_sender`, `use_key_delay_ms` are not settable over the FFI; defaults are `true`, `true`, 1000 ms. | New record `FfiParticipationConfig { compat, manage_media_keys, require_cross_signed_sender, use_key_delay_ms }` as the constructor argument (replaces the bare `compat`). |
|
||||
| C3 | `StickyEvents` compat sent keys as `org.matrix.msc4143.rtc.encryption_key` while deployed clients read only `io.element.call.encryption_keys`. | Made moot by C9 and reverted with it: the sticky dialect goes away entirely, so `Off` sends the spec key message and `StateEvents` the legacy one, with no middle case. |
|
||||
| C4 | `FfiMember` has no membership `event_id`; reactions relate to it (§4.3). | `Member.event_id: Option<String>` threaded through `session/dispatch.rs` → `convert/*` → `state.rs` (currently dropped at `state.rs:402`) → `FfiMember.event_id`. |
|
||||
| C5 | **Done.** Delegating the delayed leave (MSC4195) is split between the crate and the host: the draft's driver method makes the _adapter_ perform the whole delegation, its demo adapter calls a homeserver endpoint that 401s on a widget client, and Element Call today does it differently (probe, then the authorisation service's `get_token` with `delay_id`/`delay_timeout`/`delay_cs_api_url`). Nothing of this exists in the real `crates/` yet. | **The crate owns the policy; the driver keeps two primitives.** (a) `delegate_delayed_leave_via_homeserver(room, slot, member, delay_id)`: one authenticated POST to the CS API endpoint (`/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`, the spelling Element Call probes today; a widget client answers `Unsupported`). (b) `LivekitTokenRequest.delegation: Option<{ delay_id, delay_timeout_ms }>`: the adapter appends `delay_id`, `delay_timeout` and `delay_cs_api_url` (its own homeserver URL) to the `get_token` / `sfu/get` body it already sends. The own-membership machine tries (a) first and, on `Unsupported` or any failure, (b) against the transport we publish on, i.e. Element Call's OpenID → JWT → scheduled-event path; only if both fail does it keep restarting the switch itself. **Arm-after-confirm:** the short delayed leave stays armed through the join; delegation arms a second, ≥ 1 h delayed leave, delegates _that_, and cancels the short one on success (or the long one on failure), so no moment is left without an armed leave and a failed delegation costs nothing. `KeepAlive::Delegated` says which route succeeded. The interim C5 (service URL and delay on the request) and Element Call's driver-side probe and JWT delegation are replaced by this. **Real-backend check (2026-09-15):** both routes send the MSC4195 `member` _claims_ (`{ id, claimed_user_id, claimed_device_id }`), not the member block of the event. The homeserver route body is what lk-jwt-service 0.7 accepts behind a Synapse that proxies `rtc/livekit/*` to it (MSC4512, `backend/app-service.yaml`): `{ url, room_id, slot_id, member, delay_id, delay_timeout }`, where `url` is the SFU websocket URL the transport's token named (the service checks it is its own). The transport resolver therefore returns `ResolvedTransport { transport, sfu_url }` and the machine carries the url into `Action::DelegateViaHomeserver`; without an SFU url it goes straight to the service route, and a receive-only member (no transport) delegates nothing. Verified end to end against `pnpm backend`: the homeserver route takes the 1 h leave (`KeepAlive::Delegated { via: Homeserver }`, one delayed event of 3 600 000 ms on the server). |
|
||||
| C6 | The transport identity is a pure function (`connections/mod.rs:116-135`) but not exported; the own identity is needed before our membership echo to set our own media key. | Export `FfiParticipationManager.own_transport_identity(): Option<String>`. |
|
||||
| C7 | Doc comment on `FfiMembership.connections` says `ws_url`s. | Fix the comment. |
|
||||
| C8 | Both converters set `display_name` / `avatar_url` to `None`, so every host would re-derive them from room members. | The session records each `m.room.member` profile (whether or not the roster condition is enforced) and stamps it on members at projection time. |
|
||||
| C9 | `ElementCallCompat::StickyEvents` models the 2025 Element Call sticky dialect (`member: {user_id, device_id, id}`, flat `rtc_transports`, `versions`, legacy key message). No deployment uses it: sticky-event calls have not shipped. | **Done.** Removed: `own_membership/compat_2025.rs`, the 2025 block in `session/convert/msc4143.rs` and its dispatch arm, the `StickyEvents` variants of `ElementCallCompat` / `FfiElementCallCompat`, and their tests and acceptance tests; `outbound_event_type` / `build_content` keep two arms (`Off`, `StateEvents`). Element Call then maps `Matrix_2_0 → Off`: spec MSC4143 sticky events with slots (which Element Call opens, C1) and the spec key message. |
|
||||
| C10 | `MediaKeyState` has `holds_our_key`, `have_their_key` and `rejection`, so a tile learns about an unsigned sender only when `require_cross_signed_sender` is on (the key is discarded, `rejection: NotCrossSigned`). With the check off the verdict is dropped on accept and the tile cannot show an unverified sender. | **Done.** The inbound key store keeps the MSC4153 verdict of the accepted key per member and exposes it as `FfiMediaKeyState.sender_cross_signed: Option<bool>` (`None` when the host could not tell). Element Call runs with the check off (§5.8) and wants to show the state on the tile until it is turned on. |
|
||||
| C11 | No way to change `application["m.call.intent"]` while joined; Element Call flips it between `audio` and `video` when the camera is toggled (`updateCallIntent`). | `update_application(intent)` on the own-membership manager, facade and FFI: while connected the membership is re-published at once on the refresh path (a failure retries like a refresh); during a join the join event carries it; refused with `NotJoined` otherwise. |
|
||||
| C12 | Homeserver connectivity lived only in Element Call's driver; the crate could not tell a dead homeserver from a quiet one, and a participation's status said nothing about it. | **Done.** `ConnectivityDriver` (`is_homeserver_connected`, `subscribe_connectivity`) joins the `MatrixDriver` sum; the FFI adds `ConnectivitySink`, the two callback methods and `FfiParticipationManager.is_homeserver_connected()`; the facade pump consumes the stream and reports `Impairment::HomeserverUnreachable { since_ts }` (Critical, sorted first) in every non-disconnected status until the driver reports the homeserver back. The web-test-app mock and js-sdk driver implement it. A matrix-rust-sdk adapter implements the same two methods later. |
|
||||
| C13 | `session()` moved (seed done, slot opened by somebody else) without any listener firing, so a host's `session$` stayed stale until a membership or status change happened to refresh it. Found by the real-backend check. | **Done.** `SessionListener` / `set_session_listener` on the FFI manager (`on_session_change` on the facade), fired publish-on-change from `refresh_outputs`; `CallParticipation.session$` is fed from it. |
|
||||
|
||||
No crate work is deferred: `update_application` is C11.
|
||||
|
||||
@@ -401,9 +405,8 @@ documented as such in `docs/`: `delayed_leave.restart_ms`,
|
||||
`FfiParticipationConfig.useKeyDelayMs`.
|
||||
|
||||
Compat: `MatrixRTCMode.Compatibility → StateEvents`, `Matrix_2_0 → Off` (spec
|
||||
MSC4143: sticky member events, slots, the spec key message). Until C9 lands the
|
||||
code still maps `Matrix_2_0` to the crate's `StickyEvents`; `compatForMode` is
|
||||
the one place that changes.
|
||||
MSC4143: sticky member events, slots, the spec key message), in
|
||||
`compatForMode`.
|
||||
|
||||
### 4.4 React tree
|
||||
|
||||
@@ -485,8 +488,8 @@ Differences from the draft, all required by the widget client:
|
||||
is one `authedRequest` on a full client and `Unsupported` on a widget
|
||||
client; `getLivekitToken` appends `delay_id`, `delay_timeout` and
|
||||
`delay_cs_api_url` (`client.baseUrl`) when the request carries a
|
||||
delegation. The crate decides when to call which (C5). Until C5 lands the
|
||||
driver still carries Element Call's probe and JWT delegation; both go then.
|
||||
delegation. The crate decides when to call which (C5). Element Call carries no
|
||||
delegation probe or policy.
|
||||
- sticky listener attached after `startClient()` resolves (the widget room
|
||||
only exists then, `embedded.ts:326`).
|
||||
- `getLivekitToken` reuses today's request shapes (`slot_id: "m.call#ROOM"`,
|
||||
@@ -530,8 +533,9 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
|
||||
9. **Delegation is the crate's alone.** Element Call always asks for it; the
|
||||
crate tries the CS API, then the authorisation service's token endpoint,
|
||||
then its own restarts, and arms the long delegated leave only once
|
||||
delegation is confirmed (C5). Element Call keeps no probe and no
|
||||
delegation code.
|
||||
delegation is confirmed (C5, done). Element Call keeps no probe and no
|
||||
delegation code; `delegated_delayed_leave.delay_ms` feeds
|
||||
`FfiJoinParams.delegatedDelayMs`.
|
||||
10. **Widget trust model:** origins synthesised from claimed device ids equal
|
||||
today's js-sdk trust level; the crate records them as
|
||||
`DeviceAttribution::Verified` because it cannot tell. Documented in the
|
||||
@@ -539,6 +543,20 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
|
||||
11. **Scratch files** go to `agent-workspace/oxidation/`; this plan lives at
|
||||
the repo root because it was asked for by name.
|
||||
12. **Branch** `toger5/oxidation`, one commit per slice.
|
||||
13. **Compatibility mode has no slot.** Under `StateEvents` the crate projects
|
||||
the session from the MSC3401 state events alone and requires the legacy
|
||||
slot id `""` (`LEGACY_SLOT_ID` in `src/state/rtc/slot.ts`,
|
||||
`slotIdForCompat`); `CallParticipation` picks it from the config and skips
|
||||
the slot check and the slot open. Found by the real-backend check: with
|
||||
`m.call#ROOM` the crate saw its own legacy membership as a candidate but
|
||||
never projected it. Element Call's own rooms already let every member send
|
||||
the legacy member state event (`state_default: 0`); a plain room does not,
|
||||
which the backend test reproduces.
|
||||
14. **Member events in encrypted rooms are Megolm-encrypted** by matrix-js-sdk
|
||||
(sticky events are timeline events; the SDK exempts only reactions and
|
||||
redactions), as they are with the js-sdk MatrixRTC code today. The driver
|
||||
decrypts them on the way in; the sticky marker (`msc4354_sticky`) stays in
|
||||
the clear. To cross-check against Element X before relying on it.
|
||||
|
||||
---
|
||||
|
||||
@@ -600,26 +618,50 @@ Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uni
|
||||
`Disconnected{LeftByHost}`; join → leave → join; scope end destroys the
|
||||
manager; fallback transport when the host throws or advertises none.
|
||||
|
||||
### S3 — view model, four slices ☐
|
||||
### S3 — view model, four slices ☑ (side by side with the js-sdk view model)
|
||||
|
||||
- **S3a** `Connection`/`ConnectionManager`/`ConnectionFactory` keyed by
|
||||
`serviceUrl`, fed by `Behavior<{ serviceUrl, wsUrl, jwt, expiresAtTs }[]>`,
|
||||
with a temporary adapter from today's `SFUConfig` so `openIDSFU` stays
|
||||
until S3c. Introduces `mockCallParticipation()` and `mockFfiMembership()` in
|
||||
`src/utils/test.ts`.
|
||||
- **S3b** `MatrixLivekitMembers` + `MatrixKeyProvider` on `FfiMembership` /
|
||||
key changes, with an adapter from `CallMembership` for the still-js-sdk
|
||||
`memberships$`.
|
||||
- **S3c** `LocalMember` on `callParticipation.join/leave` + status-derived
|
||||
connectivity; delete `LocalTransport.ts`, `RtcTransportAutoDiscovery.ts`,
|
||||
`HomeserverConnected.ts`, `openIDSFU.ts`, `enterRTCSession`.
|
||||
- **S3d** `createCallViewModel$` signature, `CallNotificationLifecycle`,
|
||||
`MatrixMemberMetadata`, `SessionBehaviors.ts` and
|
||||
`useMatrixRTCSessionMemberships.ts` deleted, `ReactionsReader` on
|
||||
`participation` + `TimelineDriver`; test kit swapped
|
||||
(`MockRTCSession`/`mockRtcMembership` deleted). To stay green before S5,
|
||||
`CallView` builds a `JsSdkMatrixDriver` from its existing `client` /
|
||||
`rtcSession.room` props as a temporary shim.
|
||||
- **Shape (2026-09-15):** the existing factory is renamed
|
||||
`createJsClientCallViewModel$` and the new `createCallViewModel$(scope,
|
||||
participation, clientDriver, …)` sits next to it; both build a
|
||||
`CallViewModelCore` and hand it to the shared `assembleCallViewModel`, so
|
||||
the layout/tile half is one piece of code and the two Matrix sides can be
|
||||
reviewed side by side. `MatrixLivekitMember.membership$` is a neutral
|
||||
`CallMember` (`userId`, `deviceId`, `memberId`, `rtcBackendIdentity`) that
|
||||
js-sdk's `CallMembership` satisfies; `CallNotificationLifecycle` takes a
|
||||
neutral `DeclineEvent`. Nothing js-sdk is deleted yet (that is S6).
|
||||
- **S3a** `remoteMembers/ParticipationConnections.ts`: one `Connection` per
|
||||
service URL in `participation.connections$`, started with the crate's
|
||||
token; a refreshed token keeps the connection (used on the next
|
||||
(re)connect). `Connection`/`ECConnectionFactory` accept a `null` client.
|
||||
Test fakes live in `src/utils/test-participation.ts`
|
||||
(`FakeParticipation`, `fakeMembership`, `fakeConnection`, `fakeMediaKey`).
|
||||
- **S3b** `remoteMembers/ParticipationMembers.ts` (remote members matched by
|
||||
`transportIdentity`, keyed by `memberId`; `callMemberOf`) and
|
||||
`e2ee/participationKeyProvider.ts` (`keyMap$` × `memberships$` ×
|
||||
`ownTransportIdentity$` → `onSetEncryptionKey`, once per (member, index,
|
||||
identity); a key that arrives before the identity waits for the roster).
|
||||
- **S3c** `localMember/LocalMedia.ts` is the LiveKit half extracted from
|
||||
`LocalMember.ts` (publisher, tracks, screen share, upstream pausing, host
|
||||
notify) and shared; `localMember/ParticipationLocalMember.ts` joins and
|
||||
leaves through the participation (slot policy from `roomInfo`, custom
|
||||
LiveKit URL as a `Publish` intent, `updateApplication` on camera toggle),
|
||||
derives connected / reconnecting / `probablyLeft` from `status$`
|
||||
(`HomeserverUnreachable` → "sync", `RestartFailing`/`Expired` →
|
||||
"probablyLeft") and the fatal error from `errorForStatus`. Deletions
|
||||
(`LocalTransport.ts`, `RtcTransportAutoDiscovery.ts`,
|
||||
`HomeserverConnected.ts`, `openIDSFU.ts`, `enterRTCSession`) wait for S6.
|
||||
- **S3d** `ParticipationCallNotification.ts` sends `org.matrix.msc4075.rtc.notification`
|
||||
after our own echo when nobody was in the session before us (resets on
|
||||
leave) and reads declines from the `TimelineDriver`;
|
||||
`remoteMembers/ParticipationMemberMetadata.ts` adapts the client driver's
|
||||
roster to `RoomMemberMap` so `createMatrixMemberMetadata$` and the ringing
|
||||
name work unchanged. Still open: `ReactionsReader` on `participation` +
|
||||
`TimelineDriver` (the new factory takes the hands/reactions observables
|
||||
as inputs like the old one; S4b), `keyRotationSuppressed$` is `constant(false)`,
|
||||
and the js-sdk test kit stays until S6. Tests:
|
||||
`CallViewModel.participation.test.ts` (real wasm, mock drivers, mocked
|
||||
LiveKit: join → connection → own tile → peer → leave; transport-missing →
|
||||
`fatalError$`), plus one file per module.
|
||||
|
||||
### S4 — React tree, two slices ☐
|
||||
|
||||
@@ -635,9 +677,17 @@ Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uni
|
||||
`ReactionsReader` keyed by `memberId`, `CallNotificationLifecycle` sending
|
||||
through the driver; tests incl. a membership re-send mid-call.
|
||||
|
||||
### S5 — hosts ☐
|
||||
### S5 — hosts ☐ (component props done 2026-09-15)
|
||||
|
||||
- `component/index.tsx`, `component/matrix-js-sdk.ts`, `component/package.json`
|
||||
- **Done:** `ElementCallProps` takes `rtcDriver` + `clientDriver` (`roomId`
|
||||
optional, asserted equal); `ElementCallClientBased` takes `client` +
|
||||
`roomId` and builds the two js-sdk drivers (the dev harness uses it); the
|
||||
driver types and the js-sdk drivers are exported from the component index.
|
||||
**Temporary:** until S4 the tree under `CallView` still runs on the
|
||||
client, so `ElementCall` requires the client driver to be
|
||||
`JsSdkElementCallMatrixClientDriver` (its `client`/`room` are public for
|
||||
this) and throws for any other driver.
|
||||
- `component/matrix-js-sdk.ts`, `component/package.json`
|
||||
(`exports`, peers), `vite-component.config.ts` (two entries, `fileName`
|
||||
function, externals shrink, `?url&no-inline`), `component/tsconfig.build.json`,
|
||||
`component/dev/Harness.tsx`, `README.md`.
|
||||
@@ -691,6 +741,7 @@ Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uni
|
||||
| four builds | | ● | | | | | ● | ● |
|
||||
| Playwright standalone + widget + component | | | | | | | ● | ● |
|
||||
| Manual: two harness panes hear each other, E2EE, hand raise, reaction, leave | | | | | | | ● | |
|
||||
| Real backend (`CallParticipation.backend.test.ts`, opt-in, both modes) | | | | ● | | | ● | |
|
||||
|
||||
---
|
||||
|
||||
@@ -728,3 +779,31 @@ gaps (§2); knip `ignore` vs `ignoreFiles`, oxlint/oxfmt ignores, `.d.ts` for
|
||||
the glue, lazy wasm boot, Storybook `beforeAll`; slice re-cut (S0a/b, S1a/b,
|
||||
S3a–d, S4a/b, temporary `CallView` shim); `sdk/main.ts` status wiring and the
|
||||
Playwright delegation helper.
|
||||
|
||||
**Real-backend check (2026-09-15), `pnpm backend` + `src/state/rtc/CallParticipation.backend.test.ts`**
|
||||
(`MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 pnpm vitest run --project unit src/state/rtc/CallParticipation.backend.test.ts`;
|
||||
two registered users with rust crypto in an encrypted room, `matrix_2_0` and
|
||||
`compatibility`): passes end to end — transport discovery from
|
||||
`/rtc/transports`, slot open + echo, sticky member event with `msc4354_sticky`,
|
||||
delayed leave, delegation via the homeserver, roster with display names,
|
||||
Olm-encrypted media keys both ways with `senderCrossSigned`, leave cancelling
|
||||
the delayed event, and `HomeserverUnreachable` raised and cleared across a
|
||||
simulated network outage. Fixed on the way: MSC4195 `member` claims and the
|
||||
homeserver route's body (`url`, `delay_timeout`; C5), the missing session
|
||||
listener (C13), the legacy slot id under compatibility mode (§5.13). Noted:
|
||||
`vite-plugin-node-polyfills` shadows `process`, so tests read the environment
|
||||
through `node:process`; Synapse develop already proxies the MSC4195 endpoint
|
||||
to lk-jwt-service; member events are Megolm-encrypted in encrypted rooms
|
||||
(§5.14). Not verified here: the widget host path (Element Web) and the
|
||||
authorisation-service route (the homeserver route is taken first on this
|
||||
stack; it is covered by unit tests only).
|
||||
|
||||
**S3 (2026-09-15):** the driver-based `createCallViewModel$` landed next to
|
||||
the renamed `createJsClientCallViewModel$` (shared `assembleCallViewModel`,
|
||||
neutral `CallMember`/`DeclineEvent`, `LocalMedia.ts` extracted); component
|
||||
props moved to the two drivers with `ElementCallClientBased` on top. Gates:
|
||||
`pnpm lint`, `format:check`, `test:unit` (803), `i18n:check`,
|
||||
`build:component` green. Open from this slice: reactions reader on the
|
||||
participation (S4b), the `?url&no-inline` wasm asset for the component build
|
||||
(the wasm is loaded lazily and inlined into the component bundle today), and
|
||||
the S6 deletions.
|
||||
|
||||
+5
-5
@@ -31,7 +31,6 @@ import {
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
type CallMembership,
|
||||
MatrixRTCSessionEvent,
|
||||
MatrixRTCSessionManager,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -48,7 +47,7 @@ import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/roo
|
||||
import { type Behavior, constant } from "../src/state/Behavior";
|
||||
import {
|
||||
callViewModelOptionsFromParams,
|
||||
createCallViewModel$,
|
||||
createJsClientCallViewModel$,
|
||||
} from "../src/state/CallViewModel/CallViewModel";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { getUrlParams } from "../src/UrlParams";
|
||||
@@ -59,6 +58,7 @@ import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { initializeWidget } from "../src/widget";
|
||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||
import { type CallMember } from "../src/state/CallViewModel/remoteMembers/MatrixLivekitMembers";
|
||||
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||
import { observeElementSize$ } from "../src/utils/elementSize";
|
||||
|
||||
@@ -83,7 +83,7 @@ interface MatrixRTCSdk {
|
||||
remoteMembers$: Behavior<
|
||||
{
|
||||
connection: Connection | null;
|
||||
membership: CallMembership;
|
||||
membership: CallMember;
|
||||
participant: LocalParticipant | RemoteParticipant | null;
|
||||
}[]
|
||||
>;
|
||||
@@ -92,7 +92,7 @@ interface MatrixRTCSdk {
|
||||
*/
|
||||
localMember$: Behavior<{
|
||||
connection: Connection | null;
|
||||
membership: CallMembership;
|
||||
membership: CallMember;
|
||||
participant: LocalParticipant | null;
|
||||
} | null>;
|
||||
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
|
||||
@@ -142,7 +142,7 @@ export async function createMatrixRTCSdk(
|
||||
);
|
||||
|
||||
// call view model
|
||||
const callViewModel = createCallViewModel$(
|
||||
const callViewModel = createJsClientCallViewModel$(
|
||||
scope,
|
||||
rtcSession,
|
||||
room,
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "./MockRtcMatrixDriver";
|
||||
|
||||
const config: FfiParticipationConfig = {
|
||||
compat: FfiElementCallCompat.StickyEvents,
|
||||
compat: FfiElementCallCompat.Off,
|
||||
manageMediaKeys: true,
|
||||
requireCrossSignedSender: false,
|
||||
useKeyDelayMs: 50n,
|
||||
@@ -41,6 +41,7 @@ const joinParams = {
|
||||
keepAliveTimeoutMs: 15_000n,
|
||||
degradedLifetimeMs: undefined,
|
||||
delegateDelayedLeave: false,
|
||||
delegatedDelayMs: 3_600_000n,
|
||||
};
|
||||
|
||||
const publish = (): FfiTransportIntent =>
|
||||
@@ -117,10 +118,15 @@ describe("MockRtcMatrixDriver as the crate's driver", () => {
|
||||
await waitFor("key exchange", () =>
|
||||
manager.keyMap().some((k) => k.memberId === peer.memberId),
|
||||
);
|
||||
// StickyEvents compat: our key went out in the deployed dialect
|
||||
// spec MSC4143: the key message Element X's crates read too
|
||||
expect(driver.calls("toDevice")[0].eventType).toBe(
|
||||
"io.element.call.encryption_keys",
|
||||
"org.matrix.msc4143.rtc.encryption_key",
|
||||
);
|
||||
// the tile carries the sender's MSC4153 verdict even with the check off
|
||||
expect(
|
||||
manager.memberships().find((m) => m.member.memberId === peer.memberId)
|
||||
?.mediaKey?.senderCrossSigned,
|
||||
).toBe(true);
|
||||
driver.peerLeaves(peer);
|
||||
expect(
|
||||
manager.memberships().find((m) => m.member.memberId === peer.memberId)
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
type FfiSendEventResponse,
|
||||
type FfiToDeviceDelivery,
|
||||
type FfiToDeviceRecipient,
|
||||
type FfiHomeserverDelegationRequest,
|
||||
type FfiTransportDelegationRequest,
|
||||
type RoomEventSinkLike,
|
||||
type StateUpdateSinkLike,
|
||||
type ToDeviceSinkLike,
|
||||
@@ -78,14 +80,8 @@ export type OutboundCall =
|
||||
}
|
||||
| { 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: "delegateViaHomeserver"; request: FfiHomeserverDelegationRequest }
|
||||
| { kind: "delegateViaTransport"; request: FfiTransportDelegationRequest }
|
||||
| {
|
||||
kind: "toDevice";
|
||||
recipients: FfiToDeviceRecipient[];
|
||||
@@ -137,6 +133,10 @@ export class MockRtcMatrixDriver implements RtcMatrixDriver {
|
||||
public refuseStickyEvents = false;
|
||||
/** Make `getRtcTransports` fail rather than answer. */
|
||||
public failTransportDiscovery = false;
|
||||
/** No MSC4195 endpoint on the homeserver: the crate falls back to the service. */
|
||||
public refuseHomeserverDelegation = false;
|
||||
/** The authorisation service refuses the delegation too. */
|
||||
public refuseTransportDelegation = false;
|
||||
public roomState: RawEvent[];
|
||||
public transports: FfiRtcTransport[];
|
||||
/** Simulated peers answer our media key with theirs (index 0). */
|
||||
@@ -299,22 +299,21 @@ export class MockRtcMatrixDriver implements RtcMatrixDriver {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async delegateLivekitDelayedLeave(
|
||||
roomId: string,
|
||||
slotId: string,
|
||||
_memberJson: string,
|
||||
delayId: string,
|
||||
livekitServiceUrl: string | undefined,
|
||||
delayMs: bigint,
|
||||
public async delegateDelayedLeaveViaHomeserver(
|
||||
request: FfiHomeserverDelegationRequest,
|
||||
): Promise<void> {
|
||||
this.record({
|
||||
kind: "delegateDelayedLeave",
|
||||
roomId,
|
||||
slotId,
|
||||
delayId,
|
||||
livekitServiceUrl,
|
||||
delayMs,
|
||||
});
|
||||
this.record({ kind: "delegateViaHomeserver", request });
|
||||
if (this.refuseHomeserverDelegation)
|
||||
throw new RtcError.Unsupported("M_UNRECOGNIZED: no delegation endpoint");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async delegateDelayedLeaveViaTransport(
|
||||
request: FfiTransportDelegationRequest,
|
||||
): Promise<void> {
|
||||
this.record({ kind: "delegateViaTransport", request });
|
||||
if (this.refuseTransportDelegation)
|
||||
throw new RtcError.Http("503: the service refused");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
|
||||
@@ -64,8 +64,13 @@ export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClie
|
||||
private capabilities: Promise<DriverCapabilities> | null = null;
|
||||
|
||||
public constructor(
|
||||
private readonly client: MatrixClient,
|
||||
private readonly room: Room,
|
||||
/**
|
||||
* The client and room this driver wraps. Public only for the React tree
|
||||
* that still runs on matrix-js-sdk directly (`CallView` and below); once
|
||||
* that tree reads the drivers (plan slice S4) these become private.
|
||||
*/
|
||||
public readonly client: MatrixClient,
|
||||
public readonly room: Room,
|
||||
options: JsSdkElementCallMatrixClientDriverOptions = {},
|
||||
) {
|
||||
const userId = client.getUserId();
|
||||
|
||||
@@ -250,19 +250,45 @@ describe("JsSdkRtcMatrixDriver", () => {
|
||||
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
|
||||
});
|
||||
|
||||
it("delegates the delayed leave through the authorisation service's token endpoint", async () => {
|
||||
const { driver } = fullClient();
|
||||
it("offers both delegation primitives: the homeserver endpoint and the token endpoint", async () => {
|
||||
const { client, driver } = fullClient();
|
||||
client.http = { authedRequest: vi.fn(async () => Promise.resolve({})) };
|
||||
await driver.delegateDelayedLeaveViaHomeserver({
|
||||
sfuUrl: "wss://sfu.example.org",
|
||||
livekitServiceUrl: LK,
|
||||
roomId: ROOM_ID,
|
||||
slotId: "m.call#ROOM",
|
||||
memberJson,
|
||||
delayId: "delay-1",
|
||||
delayTimeoutMs: 3_600_000n,
|
||||
});
|
||||
expect(client.http.authedRequest).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/rtc/livekit/delegate_delayed_leave",
|
||||
undefined,
|
||||
{
|
||||
url: "wss://sfu.example.org",
|
||||
room_id: ROOM_ID,
|
||||
slot_id: "m.call#ROOM",
|
||||
member: JSON.parse(memberJson),
|
||||
delay_id: "delay-1",
|
||||
delay_timeout: 3_600_000,
|
||||
},
|
||||
{ prefix: "/_matrix/client/unstable/io.element.msc4195" },
|
||||
);
|
||||
|
||||
fetchMock.mockImplementation(async () =>
|
||||
Promise.resolve(jsonResponse({ jwt: "discarded" })),
|
||||
);
|
||||
await driver.delegateLivekitDelayedLeave(
|
||||
ROOM_ID,
|
||||
"m.call#ROOM",
|
||||
await driver.delegateDelayedLeaveViaTransport({
|
||||
livekitServiceUrl: LK,
|
||||
roomId: ROOM_ID,
|
||||
slotId: "m.call#ROOM",
|
||||
memberJson,
|
||||
"delay-1",
|
||||
LK,
|
||||
3_600_000n,
|
||||
);
|
||||
delayId: "delay-1",
|
||||
delayTimeoutMs: 3_600_000n,
|
||||
legacySfuGet: false,
|
||||
});
|
||||
const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
@@ -273,17 +299,6 @@ describe("JsSdkRtcMatrixDriver", () => {
|
||||
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 () => {
|
||||
@@ -418,6 +433,21 @@ describe("JsSdkRtcMatrixDriver", () => {
|
||||
expect(crossSigned).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cannot delegate through the homeserver, so the crate falls back to the service", async () => {
|
||||
const { driver } = widgetClient();
|
||||
await expect(
|
||||
driver.delegateDelayedLeaveViaHomeserver({
|
||||
sfuUrl: "wss://sfu.example.org",
|
||||
livekitServiceUrl: LK,
|
||||
roomId: ROOM_ID,
|
||||
slotId: "m.call#ROOM",
|
||||
memberJson,
|
||||
delayId: "delay-1",
|
||||
delayTimeoutMs: 3_600_000n,
|
||||
}),
|
||||
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
|
||||
});
|
||||
|
||||
it("treats member events in an encrypted room as encrypted by the claimed device", async () => {
|
||||
const { room, driver } = widgetClient();
|
||||
const roomSink = sink();
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
MatrixError,
|
||||
Method,
|
||||
type ReceivedToDeviceMessage,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
@@ -52,6 +53,8 @@ import {
|
||||
type FfiSendEventResponse,
|
||||
type FfiToDeviceDelivery,
|
||||
type FfiToDeviceRecipient,
|
||||
type FfiHomeserverDelegationRequest,
|
||||
type FfiTransportDelegationRequest,
|
||||
type RoomEventSinkLike,
|
||||
type StateUpdateSinkLike,
|
||||
type ToDeviceSinkLike,
|
||||
@@ -200,38 +203,59 @@ export class JsSdkRtcMatrixDriver implements RtcMatrixDriver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.)
|
||||
* MSC4195 through the homeserver's CS API (a homeserver proxying
|
||||
* `rtc/livekit/*` to the authorisation service, MSC4512). One
|
||||
* authenticated request with the body lk-jwt-service 0.7 accepts; the
|
||||
* crate decides when to make it and what to try when it fails. Widget: the
|
||||
* widget client has no access token, so this is `Unsupported` there.
|
||||
*/
|
||||
public async delegateLivekitDelayedLeave(
|
||||
roomId: string,
|
||||
slotId: string,
|
||||
memberJson: string,
|
||||
delayId: string,
|
||||
livekitServiceUrl: string | undefined,
|
||||
delayMs: bigint,
|
||||
public async delegateDelayedLeaveViaHomeserver(
|
||||
request: FfiHomeserverDelegationRequest,
|
||||
): Promise<void> {
|
||||
if (livekitServiceUrl === undefined)
|
||||
if (this.widget)
|
||||
throw new RtcError.Unsupported(
|
||||
"A receive-only member has no transport to delegate to",
|
||||
"A widget client cannot make authenticated homeserver requests",
|
||||
);
|
||||
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.client.http.authedRequest(
|
||||
Method.Post,
|
||||
"/rtc/livekit/delegate_delayed_leave",
|
||||
undefined,
|
||||
{
|
||||
// The SFU our token named; the service checks it is its own.
|
||||
url: request.sfuUrl,
|
||||
room_id: request.roomId,
|
||||
slot_id: request.slotId,
|
||||
member: JSON.parse(request.memberJson) as MemberClaims,
|
||||
delay_id: request.delayId,
|
||||
// Optional once the homeserver can be asked for the delay by id.
|
||||
delay_timeout: Number(request.delayTimeoutMs),
|
||||
},
|
||||
{ prefix: "/_matrix/client/unstable/io.element.msc4195" },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* MSC4195 through the authorisation service: the token request Element Call
|
||||
* has always made, with `delay_id`, `delay_timeout` and this client's
|
||||
* homeserver URL added. The token in the answer is discarded.
|
||||
*/
|
||||
public async delegateDelayedLeaveViaTransport(
|
||||
request: FfiTransportDelegationRequest,
|
||||
): Promise<void> {
|
||||
await guard(async () => {
|
||||
await this.requestToken(
|
||||
livekitServiceUrl,
|
||||
roomId,
|
||||
slotId,
|
||||
member,
|
||||
false,
|
||||
delegation,
|
||||
request.livekitServiceUrl,
|
||||
request.roomId,
|
||||
request.slotId,
|
||||
JSON.parse(request.memberJson) as MemberClaims,
|
||||
request.legacySfuGet,
|
||||
{
|
||||
delay_id: request.delayId,
|
||||
delay_timeout: Number(request.delayTimeoutMs),
|
||||
delay_cs_api_url: this.client.baseUrl,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface FakeClient extends EventEmitter {
|
||||
redactEvent: Fn;
|
||||
getSyncState: Fn;
|
||||
getUser: (userId: string) => User | null;
|
||||
http: { authedRequest: Fn };
|
||||
}
|
||||
|
||||
export interface FakeRoom extends EventEmitter {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
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 } from "vitest";
|
||||
import { KeyProviderEvent } from "livekit-client";
|
||||
|
||||
import { testScope } from "../utils/test";
|
||||
import {
|
||||
FakeParticipation,
|
||||
fakeMediaKey,
|
||||
fakeMembership,
|
||||
} from "../utils/test-participation";
|
||||
import { waitFor } from "../driver/MockRtcMatrixDriver";
|
||||
import { ParticipationKeyProvider } from "./participationKeyProvider";
|
||||
|
||||
interface SetKey {
|
||||
participantIdentity: string | undefined;
|
||||
keyIndex: number | undefined;
|
||||
}
|
||||
|
||||
function attached(): {
|
||||
participation: FakeParticipation;
|
||||
setKeys: SetKey[];
|
||||
} {
|
||||
const participation = new FakeParticipation();
|
||||
const provider = new ParticipationKeyProvider();
|
||||
const setKeys: SetKey[] = [];
|
||||
provider.on(KeyProviderEvent.SetKey, ({ participantIdentity, keyIndex }) =>
|
||||
setKeys.push({ participantIdentity, keyIndex }),
|
||||
);
|
||||
provider.attach(testScope(), participation);
|
||||
return { participation, setKeys };
|
||||
}
|
||||
|
||||
describe("ParticipationKeyProvider", () => {
|
||||
it("hands our own key to LiveKit under our transport identity", async () => {
|
||||
const { participation, setKeys } = attached();
|
||||
participation.ownMemberId$.next("m-me");
|
||||
participation.ownTransportIdentity$.next("lk-me");
|
||||
participation.keyMap$.next([fakeMediaKey({ memberId: "m-me", index: 0 })]);
|
||||
await waitFor("own key set", () => setKeys.length === 1);
|
||||
expect(setKeys).toEqual([{ participantIdentity: "lk-me", keyIndex: 0 }]);
|
||||
});
|
||||
|
||||
it("waits for a peer's transport identity and sets each key once", async () => {
|
||||
const { participation, setKeys } = attached();
|
||||
// The key arrives before the roster knows the member's identity.
|
||||
participation.keyMap$.next([
|
||||
fakeMediaKey({ memberId: "m-peer", index: 2 }),
|
||||
]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(setKeys).toEqual([]);
|
||||
|
||||
participation.setMemberships([
|
||||
fakeMembership({
|
||||
member: { memberId: "m-peer" },
|
||||
transportIdentity: "lk-peer",
|
||||
}),
|
||||
]);
|
||||
await waitFor("peer key set", () => setKeys.length === 1);
|
||||
expect(setKeys).toEqual([{ participantIdentity: "lk-peer", keyIndex: 2 }]);
|
||||
|
||||
// The map is re-emitted (a rotation elsewhere): no second delivery.
|
||||
participation.keyMap$.next([
|
||||
fakeMediaKey({ memberId: "m-peer", index: 2 }),
|
||||
fakeMediaKey({ memberId: "m-peer", index: 3 }),
|
||||
]);
|
||||
await waitFor("next index set", () => setKeys.length === 2);
|
||||
expect(setKeys[1]).toEqual({ participantIdentity: "lk-peer", keyIndex: 3 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
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 { BaseKeyProvider } from "livekit-client";
|
||||
import { combineLatest } from "rxjs";
|
||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { type Epoch, type ObservableScope } from "../state/ObservableScope";
|
||||
import { type FfiMediaKey, type FfiMembership } from "../matrix-rtc-sdk";
|
||||
|
||||
/** What this provider needs from a {@link CallParticipation}. */
|
||||
export interface ParticipationKeys {
|
||||
/** Every media key in use, ours and theirs, one per (member, index). */
|
||||
keyMap$: Behavior<FfiMediaKey[]>;
|
||||
memberships$: Behavior<Epoch<FfiMembership[]>>;
|
||||
ownMemberId$: Behavior<string | null>;
|
||||
ownTransportIdentity$: Behavior<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds the crate's media keys to livekit-client's E2EE workers.
|
||||
*
|
||||
* The crate exchanges keys per member id; LiveKit encrypts per participant
|
||||
* identity. A key is handed over as soon as both are known — a key that
|
||||
* arrives before its member's transport identity waits on the roster rather
|
||||
* than being dropped — and once per (member, index, identity), so a
|
||||
* re-emitted map does not churn the key ring.
|
||||
*/
|
||||
export class ParticipationKeyProvider extends BaseKeyProvider {
|
||||
private readonly logger: Logger;
|
||||
private readonly applied = new Set<string>();
|
||||
|
||||
public constructor() {
|
||||
super({ ratchetWindowSize: 10, keyringSize: 256 });
|
||||
this.logger = rootLogger.getChild("[ParticipationKeyProvider]");
|
||||
}
|
||||
|
||||
/** Follow the participation's keys for as long as `scope` lives. */
|
||||
public attach(
|
||||
scope: ObservableScope,
|
||||
participation: ParticipationKeys,
|
||||
): void {
|
||||
combineLatest([
|
||||
participation.keyMap$,
|
||||
participation.memberships$,
|
||||
participation.ownMemberId$,
|
||||
participation.ownTransportIdentity$,
|
||||
])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([keys, memberships, ownMemberId, ownIdentity]) => {
|
||||
for (const key of keys) {
|
||||
const identity =
|
||||
key.memberId === ownMemberId
|
||||
? ownIdentity
|
||||
: memberships.value.find(
|
||||
(m) => m.member.memberId === key.memberId,
|
||||
)?.transportIdentity;
|
||||
if (!identity) continue;
|
||||
const tag = `${key.memberId}|${key.index}|${identity}`;
|
||||
if (this.applied.has(tag)) continue;
|
||||
this.applied.add(tag);
|
||||
void this.setKey(key, identity);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async setKey(key: FfiMediaKey, identity: string): Promise<void> {
|
||||
try {
|
||||
const material = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
key.key,
|
||||
"HKDF",
|
||||
false,
|
||||
["deriveBits", "deriveKey"],
|
||||
);
|
||||
this.onSetEncryptionKey(material, identity, key.index);
|
||||
this.logger.debug(
|
||||
`Set key for participant ${identity} (member ${key.memberId}) index ${key.index}`,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Failed to import the key of member ${key.memberId} index ${key.index}`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,2 +1,2 @@
|
||||
matrix-rtc (MatrixSdkArchitectureDraft) a095ba7-dirty
|
||||
built 2026-09-15T16:54:50Z by scripts/sync-matrix-rtc-sdk.sh
|
||||
matrix-rtc (MatrixSdkArchitectureDraft) 088a598-dirty
|
||||
built 2026-09-15T18:22:09Z by scripts/sync-matrix-rtc-sdk.sh
|
||||
|
||||
+30
-20
@@ -51,17 +51,18 @@ export type UniffiForeignFutureResultVoid = {
|
||||
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 UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod6 = (uniffiHandle: bigint, request: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompletevoid, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod7 = (uniffiHandle: bigint, request: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompletevoid, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod8 = (uniffiHandle: bigint, recipients: Uint8Array, eventType: Uint8Array, contentJson: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod9 = (uniffiHandle: bigint, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod10 = (uniffiHandle: bigint, request: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod11 = (uniffiHandle: bigint, eventType: Uint8Array, stateKey: Uint8Array, limit: number, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod12 = (uniffiHandle: bigint, eventType: Uint8Array, stateKey: Uint8Array, uniffiFutureCallback: UniffiForeignFutureCompleterustBuffer, uniffiCallbackData: bigint) => UniffiForeignFutureDroppedCallbackStruct;
|
||||
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 UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod15 = (uniffiHandle: bigint, sink: bigint) => UniffiResult<void>;
|
||||
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod16 = (uniffiHandle: bigint) => number;
|
||||
type UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod17 = (uniffiHandle: bigint, sink: bigint) => UniffiResult<void>;
|
||||
type UniffiCallbackInterfaceCloneMatrixRtcMatrixDriverCallback = (handle: bigint) => UniffiResult<void>;
|
||||
type UniffiCallbackInterfaceFreeMatrixRtcMatrixDriverCallback = (handle: bigint) => void;
|
||||
export type UniffiVTableCallbackInterfaceMatrixRtcMatrixDriverCallback = {
|
||||
@@ -73,17 +74,18 @@ export type UniffiVTableCallbackInterfaceMatrixRtcMatrixDriverCallback = {
|
||||
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;
|
||||
delegate_delayed_leave_via_homeserver: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod6;
|
||||
delegate_delayed_leave_via_transport: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod7;
|
||||
send_to_device: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod8;
|
||||
get_rtc_transports: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod9;
|
||||
get_livekit_token: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod10;
|
||||
read_events: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod11;
|
||||
read_state: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod12;
|
||||
subscribe_room_events: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod13;
|
||||
subscribe_to_device_events: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod14;
|
||||
subscribe_state_updates: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod15;
|
||||
is_homeserver_connected: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod16;
|
||||
subscribe_connectivity: UniffiCallbackInterfaceMatrixRtcMatrixDriverCallbackMethod17;
|
||||
};
|
||||
type UniffiCallbackInterfaceMatrixRtcMembershipsListenerMethod0 = (uniffiHandle: bigint, memberships: Uint8Array) => UniffiResult<void>;
|
||||
type UniffiCallbackInterfaceCloneMatrixRtcMembershipsListener = (handle: bigint) => UniffiResult<void>;
|
||||
@@ -93,6 +95,14 @@ export type UniffiVTableCallbackInterfaceMatrixRtcMembershipsListener = {
|
||||
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcMembershipsListener;
|
||||
on_memberships_change: UniffiCallbackInterfaceMatrixRtcMembershipsListenerMethod0;
|
||||
};
|
||||
type UniffiCallbackInterfaceMatrixRtcSessionListenerMethod0 = (uniffiHandle: bigint, session: Uint8Array) => UniffiResult<void>;
|
||||
type UniffiCallbackInterfaceCloneMatrixRtcSessionListener = (handle: bigint) => UniffiResult<void>;
|
||||
type UniffiCallbackInterfaceFreeMatrixRtcSessionListener = (handle: bigint) => void;
|
||||
export type UniffiVTableCallbackInterfaceMatrixRtcSessionListener = {
|
||||
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcSessionListener;
|
||||
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcSessionListener;
|
||||
on_session_change: UniffiCallbackInterfaceMatrixRtcSessionListenerMethod0;
|
||||
};
|
||||
type UniffiCallbackInterfaceMatrixRtcStatusListenerMethod0 = (uniffiHandle: bigint, status: Uint8Array) => UniffiResult<void>;
|
||||
type UniffiCallbackInterfaceCloneMatrixRtcStatusListener = (handle: bigint) => UniffiResult<void>;
|
||||
type UniffiCallbackInterfaceFreeMatrixRtcStatusListener = (handle: bigint) => void;
|
||||
|
||||
+525
-50
@@ -4,7 +4,7 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
import * as wasmBundle from "./wasm-bindgen/index.js";
|
||||
import { type UniffiRustFutureContinuationCallback, type UniffiForeignFutureDroppedCallback, type UniffiForeignFutureDroppedCallbackStruct, type UniffiVTableCallbackInterfaceMatrixRtcConnectionsListener, type UniffiVTableCallbackInterfaceMatrixRtcKeyMapListener, type UniffiVTableCallbackInterfaceMatrixRtcKeyRejectedListener, type UniffiForeignFutureResultRustBuffer, type UniffiForeignFutureCompleterustBuffer, type UniffiForeignFutureResultVoid, type UniffiForeignFutureCompletevoid, type UniffiVTableCallbackInterfaceMatrixRtcMatrixDriverCallback, type UniffiVTableCallbackInterfaceMatrixRtcMembershipsListener, type UniffiVTableCallbackInterfaceMatrixRtcStatusListener,
|
||||
import { type UniffiRustFutureContinuationCallback, type UniffiForeignFutureDroppedCallback, type UniffiForeignFutureDroppedCallbackStruct, type UniffiVTableCallbackInterfaceMatrixRtcConnectionsListener, type UniffiVTableCallbackInterfaceMatrixRtcKeyMapListener, type UniffiVTableCallbackInterfaceMatrixRtcKeyRejectedListener, type UniffiForeignFutureResultRustBuffer, type UniffiForeignFutureCompleterustBuffer, type UniffiForeignFutureResultVoid, type UniffiForeignFutureCompletevoid, type UniffiVTableCallbackInterfaceMatrixRtcMatrixDriverCallback, type UniffiVTableCallbackInterfaceMatrixRtcMembershipsListener, type UniffiVTableCallbackInterfaceMatrixRtcSessionListener, type UniffiVTableCallbackInterfaceMatrixRtcStatusListener,
|
||||
} from "./matrix_rtc-ffi";
|
||||
import { type FfiConverter, type UniffiByteArray, type UniffiGcObject, type UniffiHandle, type UniffiObjectFactory, type UniffiReferenceHolder, type UniffiRustCallStatus, AbstractFfiConverterByteArray, Cursor, FfiConverterArray, FfiConverterArrayBuffer, FfiConverterBool, FfiConverterObject, FfiConverterObjectWithCallbacks, FfiConverterOptional, FfiConverterUInt32, FfiConverterUInt64, FfiConverterUInt8, RustBuffer, UniffiAbstractObject, UniffiEnum, UniffiError, UniffiInternalError, UniffiResult, UniffiRustCaller, destructorGuardSymbol, pointerLiteralSymbol, uniffiCreateFfiConverterString, uniffiCreateRecord, uniffiRustCallAsync, uniffiTraitInterfaceCall, uniffiTraitInterfaceCallAsyncWithError, uniffiTypeNameSymbol, variantOrdinalSymbol,
|
||||
} from "@ubjs/core";
|
||||
@@ -554,6 +554,86 @@ const FfiConverterTypeFfiExcludedCandidate = (() => {
|
||||
return new FFIConverter();
|
||||
})();
|
||||
|
||||
/**
|
||||
* See `driver::TransportDelegationRequest`: the token request with the
|
||||
* MSC4195 through the homeserver: `POST
|
||||
* /_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`
|
||||
* with the body `{ url, room_id, slot_id, member, delay_id, delay_timeout }`
|
||||
* — `url` is `sfu_url`, `member` is `member_json` parsed, `delay_timeout`
|
||||
* is `delay_timeout_ms`.
|
||||
*/
|
||||
export type FfiHomeserverDelegationRequest = {
|
||||
/**
|
||||
* The SFU websocket URL our token named: the service checks it is its own.
|
||||
*/
|
||||
sfuUrl: string,
|
||||
/**
|
||||
* The authorisation service of the transport we publish on.
|
||||
*/
|
||||
livekitServiceUrl: string,
|
||||
roomId: string,
|
||||
slotId: string,
|
||||
/**
|
||||
* MSC4195 member claims `{ id, claimed_user_id, claimed_device_id }`.
|
||||
*/
|
||||
memberJson: string,
|
||||
delayId: string,
|
||||
delayTimeoutMs: bigint
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated factory for {@link FfiHomeserverDelegationRequest} record objects.
|
||||
*/
|
||||
export const FfiHomeserverDelegationRequest = (() => {
|
||||
const defaults = () => ({
|
||||
});
|
||||
const create = (() => {
|
||||
return uniffiCreateRecord<FfiHomeserverDelegationRequest, ReturnType<typeof defaults>>(defaults);
|
||||
})();
|
||||
return Object.freeze({
|
||||
create,
|
||||
new: create,
|
||||
defaults: () => Object.freeze(defaults()) as Partial<FfiHomeserverDelegationRequest>,
|
||||
});
|
||||
})();
|
||||
|
||||
const FfiConverterTypeFfiHomeserverDelegationRequest = (() => {
|
||||
type TypeName = FfiHomeserverDelegationRequest;
|
||||
class FFIConverter extends AbstractFfiConverterByteArray<TypeName> {
|
||||
readFromCursor(c: Cursor): TypeName {
|
||||
return {
|
||||
sfuUrl: FfiConverterString.readFromCursor(c),
|
||||
livekitServiceUrl: FfiConverterString.readFromCursor(c),
|
||||
roomId: FfiConverterString.readFromCursor(c),
|
||||
slotId: FfiConverterString.readFromCursor(c),
|
||||
memberJson: FfiConverterString.readFromCursor(c),
|
||||
delayId: FfiConverterString.readFromCursor(c),
|
||||
delayTimeoutMs: FfiConverterUInt64.readFromCursor(c)
|
||||
};
|
||||
}
|
||||
writeIntoCursor(value: TypeName, c: Cursor): void {
|
||||
FfiConverterString.writeIntoCursor(value.sfuUrl, c);
|
||||
FfiConverterString.writeIntoCursor(value.livekitServiceUrl, c);
|
||||
FfiConverterString.writeIntoCursor(value.roomId, c);
|
||||
FfiConverterString.writeIntoCursor(value.slotId, c);
|
||||
FfiConverterString.writeIntoCursor(value.memberJson, c);
|
||||
FfiConverterString.writeIntoCursor(value.delayId, c);
|
||||
FfiConverterUInt64.writeIntoCursor(value.delayTimeoutMs, c);
|
||||
}
|
||||
allocationSize(value: TypeName): number {
|
||||
return FfiConverterString.allocationSize(value.sfuUrl) +
|
||||
FfiConverterString.allocationSize(value.livekitServiceUrl) +
|
||||
FfiConverterString.allocationSize(value.roomId) +
|
||||
FfiConverterString.allocationSize(value.slotId) +
|
||||
FfiConverterString.allocationSize(value.memberJson) +
|
||||
FfiConverterString.allocationSize(value.delayId) +
|
||||
FfiConverterUInt64.allocationSize(value.delayTimeoutMs);
|
||||
|
||||
}
|
||||
};
|
||||
return new FFIConverter();
|
||||
})();
|
||||
|
||||
export type FfiJoinParams = {
|
||||
applicationType: string,
|
||||
/**
|
||||
@@ -566,7 +646,17 @@ export type FfiJoinParams = {
|
||||
* Lifetime when the homeserver refuses delayed events (default 5 min).
|
||||
*/
|
||||
degradedLifetimeMs?: bigint,
|
||||
delegateDelayedLeave: boolean
|
||||
/**
|
||||
* Hand the delayed leave to the SFU (MSC4195): the crate tries the
|
||||
* homeserver, then the authorisation service, then keeps restarting the
|
||||
* leave itself.
|
||||
*/
|
||||
delegateDelayedLeave: boolean,
|
||||
/**
|
||||
* The delay of the delegated leave (MSC4195 asks for ≥ 1 h); the short
|
||||
* `keep_alive_timeout_ms` leave stays armed until delegation is confirmed.
|
||||
*/
|
||||
delegatedDelayMs: bigint
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -595,7 +685,8 @@ const FfiConverterTypeFfiJoinParams = (() => {
|
||||
stickyDurationMs: FfiConverterUInt64.readFromCursor(c),
|
||||
keepAliveTimeoutMs: FfiConverterUInt64.readFromCursor(c),
|
||||
degradedLifetimeMs: FfiConverterOptionalUInt64.readFromCursor(c),
|
||||
delegateDelayedLeave: FfiConverterBool.readFromCursor(c)
|
||||
delegateDelayedLeave: FfiConverterBool.readFromCursor(c),
|
||||
delegatedDelayMs: FfiConverterUInt64.readFromCursor(c)
|
||||
};
|
||||
}
|
||||
writeIntoCursor(value: TypeName, c: Cursor): void {
|
||||
@@ -605,6 +696,7 @@ const FfiConverterTypeFfiJoinParams = (() => {
|
||||
FfiConverterUInt64.writeIntoCursor(value.keepAliveTimeoutMs, c);
|
||||
FfiConverterOptionalUInt64.writeIntoCursor(value.degradedLifetimeMs, c);
|
||||
FfiConverterBool.writeIntoCursor(value.delegateDelayedLeave, c);
|
||||
FfiConverterUInt64.writeIntoCursor(value.delegatedDelayMs, c);
|
||||
}
|
||||
allocationSize(value: TypeName): number {
|
||||
return FfiConverterString.allocationSize(value.applicationType) +
|
||||
@@ -612,7 +704,8 @@ const FfiConverterTypeFfiJoinParams = (() => {
|
||||
FfiConverterUInt64.allocationSize(value.stickyDurationMs) +
|
||||
FfiConverterUInt64.allocationSize(value.keepAliveTimeoutMs) +
|
||||
FfiConverterOptionalUInt64.allocationSize(value.degradedLifetimeMs) +
|
||||
FfiConverterBool.allocationSize(value.delegateDelayedLeave);
|
||||
FfiConverterBool.allocationSize(value.delegateDelayedLeave) +
|
||||
FfiConverterUInt64.allocationSize(value.delegatedDelayMs);
|
||||
|
||||
}
|
||||
};
|
||||
@@ -915,7 +1008,13 @@ export type FfiMediaKeyState = {
|
||||
/**
|
||||
* Why their most recent key was discarded, while we still lack one.
|
||||
*/
|
||||
rejection?: FfiKeyRejection
|
||||
rejection?: FfiKeyRejection,
|
||||
/**
|
||||
* MSC4153: whether the device that sent the key we hold from them is
|
||||
* cross-signed by its owner; `None` while we hold none or the host
|
||||
* could not tell. Reported whether or not the check is enforced.
|
||||
*/
|
||||
senderCrossSigned?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -941,18 +1040,21 @@ const FfiConverterTypeFfiMediaKeyState = (() => {
|
||||
return {
|
||||
holdsOurKey: FfiConverterBool.readFromCursor(c),
|
||||
haveTheirKey: FfiConverterBool.readFromCursor(c),
|
||||
rejection: FfiConverterOptionalTypeFfiKeyRejection.readFromCursor(c)
|
||||
rejection: FfiConverterOptionalTypeFfiKeyRejection.readFromCursor(c),
|
||||
senderCrossSigned: FfiConverterOptionalBoolean.readFromCursor(c)
|
||||
};
|
||||
}
|
||||
writeIntoCursor(value: TypeName, c: Cursor): void {
|
||||
FfiConverterBool.writeIntoCursor(value.holdsOurKey, c);
|
||||
FfiConverterBool.writeIntoCursor(value.haveTheirKey, c);
|
||||
FfiConverterOptionalTypeFfiKeyRejection.writeIntoCursor(value.rejection, c);
|
||||
FfiConverterOptionalBoolean.writeIntoCursor(value.senderCrossSigned, c);
|
||||
}
|
||||
allocationSize(value: TypeName): number {
|
||||
return FfiConverterBool.allocationSize(value.holdsOurKey) +
|
||||
FfiConverterBool.allocationSize(value.haveTheirKey) +
|
||||
FfiConverterOptionalTypeFfiKeyRejection.allocationSize(value.rejection);
|
||||
FfiConverterOptionalTypeFfiKeyRejection.allocationSize(value.rejection) +
|
||||
FfiConverterOptionalBoolean.allocationSize(value.senderCrossSigned);
|
||||
|
||||
}
|
||||
};
|
||||
@@ -1136,7 +1238,6 @@ const FfiConverterTypeFfiMembershipPublication = (() => {
|
||||
*/
|
||||
export enum FfiElementCallCompat {
|
||||
Off,
|
||||
StickyEvents,
|
||||
StateEvents
|
||||
}
|
||||
|
||||
@@ -1146,16 +1247,14 @@ const FfiConverterTypeFfiElementCallCompat = (() => {
|
||||
readFromCursor(c: Cursor): TypeName {
|
||||
switch (c.readI32()) {
|
||||
case 1: return FfiElementCallCompat.Off;
|
||||
case 2: return FfiElementCallCompat.StickyEvents;
|
||||
case 3: return FfiElementCallCompat.StateEvents;
|
||||
case 2: return FfiElementCallCompat.StateEvents;
|
||||
default: throw new UniffiInternalError.UnexpectedEnumCase();
|
||||
}
|
||||
}
|
||||
writeIntoCursor(value: TypeName, c: Cursor): void {
|
||||
switch (value) {
|
||||
case FfiElementCallCompat.Off: return c.writeI32(1);
|
||||
case FfiElementCallCompat.StickyEvents: return c.writeI32(2);
|
||||
case FfiElementCallCompat.StateEvents: return c.writeI32(3);
|
||||
case FfiElementCallCompat.StateEvents: return c.writeI32(2);
|
||||
}
|
||||
}
|
||||
allocationSize(value: TypeName): number {
|
||||
@@ -1518,6 +1617,76 @@ const FfiConverterTypeFfiToDeviceDelivery = (() => {
|
||||
return new FFIConverter();
|
||||
})();
|
||||
|
||||
/**
|
||||
* MSC4195 delay fields, sent to `{livekit_service_url}/get_token` (or
|
||||
* `/sfu/get` with `legacy_sfu_get`).
|
||||
*/
|
||||
export type FfiTransportDelegationRequest = {
|
||||
livekitServiceUrl: string,
|
||||
roomId: string,
|
||||
slotId: string,
|
||||
/**
|
||||
* MSC4195 member claims `{ id, claimed_user_id, claimed_device_id }`.
|
||||
*/
|
||||
memberJson: string,
|
||||
delayId: string,
|
||||
delayTimeoutMs: bigint,
|
||||
legacySfuGet: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Generated factory for {@link FfiTransportDelegationRequest} record objects.
|
||||
*/
|
||||
export const FfiTransportDelegationRequest = (() => {
|
||||
const defaults = () => ({
|
||||
});
|
||||
const create = (() => {
|
||||
return uniffiCreateRecord<FfiTransportDelegationRequest, ReturnType<typeof defaults>>(defaults);
|
||||
})();
|
||||
return Object.freeze({
|
||||
create,
|
||||
new: create,
|
||||
defaults: () => Object.freeze(defaults()) as Partial<FfiTransportDelegationRequest>,
|
||||
});
|
||||
})();
|
||||
|
||||
const FfiConverterTypeFfiTransportDelegationRequest = (() => {
|
||||
type TypeName = FfiTransportDelegationRequest;
|
||||
class FFIConverter extends AbstractFfiConverterByteArray<TypeName> {
|
||||
readFromCursor(c: Cursor): TypeName {
|
||||
return {
|
||||
livekitServiceUrl: FfiConverterString.readFromCursor(c),
|
||||
roomId: FfiConverterString.readFromCursor(c),
|
||||
slotId: FfiConverterString.readFromCursor(c),
|
||||
memberJson: FfiConverterString.readFromCursor(c),
|
||||
delayId: FfiConverterString.readFromCursor(c),
|
||||
delayTimeoutMs: FfiConverterUInt64.readFromCursor(c),
|
||||
legacySfuGet: FfiConverterBool.readFromCursor(c)
|
||||
};
|
||||
}
|
||||
writeIntoCursor(value: TypeName, c: Cursor): void {
|
||||
FfiConverterString.writeIntoCursor(value.livekitServiceUrl, c);
|
||||
FfiConverterString.writeIntoCursor(value.roomId, c);
|
||||
FfiConverterString.writeIntoCursor(value.slotId, c);
|
||||
FfiConverterString.writeIntoCursor(value.memberJson, c);
|
||||
FfiConverterString.writeIntoCursor(value.delayId, c);
|
||||
FfiConverterUInt64.writeIntoCursor(value.delayTimeoutMs, c);
|
||||
FfiConverterBool.writeIntoCursor(value.legacySfuGet, c);
|
||||
}
|
||||
allocationSize(value: TypeName): number {
|
||||
return FfiConverterString.allocationSize(value.livekitServiceUrl) +
|
||||
FfiConverterString.allocationSize(value.roomId) +
|
||||
FfiConverterString.allocationSize(value.slotId) +
|
||||
FfiConverterString.allocationSize(value.memberJson) +
|
||||
FfiConverterString.allocationSize(value.delayId) +
|
||||
FfiConverterUInt64.allocationSize(value.delayTimeoutMs) +
|
||||
FfiConverterBool.allocationSize(value.legacySfuGet);
|
||||
|
||||
}
|
||||
};
|
||||
return new FFIConverter();
|
||||
})();
|
||||
|
||||
/**
|
||||
* Which pump stopped, for [`FfiDisconnectCause::ManagerStopped`].
|
||||
*/
|
||||
@@ -1591,6 +1760,43 @@ const FfiConverterTypeFfiDelayedLeaveOutcome = (() => {
|
||||
return new FFIConverter();
|
||||
})();
|
||||
|
||||
/**
|
||||
* How the delayed leave was handed to the SFU (MSC4195).
|
||||
*/
|
||||
export enum FfiDelegationRoute {
|
||||
/**
|
||||
* The homeserver's CS API endpoint.
|
||||
*/
|
||||
Homeserver,
|
||||
/**
|
||||
* The authorisation service's token endpoint with the delay fields.
|
||||
*/
|
||||
AuthorisationService
|
||||
}
|
||||
|
||||
const FfiConverterTypeFfiDelegationRoute = (() => {
|
||||
type TypeName = FfiDelegationRoute;
|
||||
class FFIConverter extends AbstractFfiConverterByteArray<TypeName> {
|
||||
readFromCursor(c: Cursor): TypeName {
|
||||
switch (c.readI32()) {
|
||||
case 1: return FfiDelegationRoute.Homeserver;
|
||||
case 2: return FfiDelegationRoute.AuthorisationService;
|
||||
default: throw new UniffiInternalError.UnexpectedEnumCase();
|
||||
}
|
||||
}
|
||||
writeIntoCursor(value: TypeName, c: Cursor): void {
|
||||
switch (value) {
|
||||
case FfiDelegationRoute.Homeserver: return c.writeI32(1);
|
||||
case FfiDelegationRoute.AuthorisationService: return c.writeI32(2);
|
||||
}
|
||||
}
|
||||
allocationSize(value: TypeName): number {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
return new FFIConverter();
|
||||
})();
|
||||
|
||||
|
||||
// Enum: FfiJoinError
|
||||
export enum FfiJoinError_Tags {
|
||||
@@ -3319,7 +3525,7 @@ inner: {delayMs: bigint; lastRestartTs: bigint; firesAtTs: bigint }): Armed_ {
|
||||
type Delegated__interface = {
|
||||
tag: FfiKeepAlive_Tags.Delegated;
|
||||
inner:
|
||||
Readonly<{delegatedAtTs: bigint; earliestFireTs: bigint}>
|
||||
Readonly<{delegatedAtTs: bigint; earliestFireTs: bigint; via: FfiDelegationRoute}>
|
||||
};
|
||||
/**
|
||||
* Handed to the SFU (MSC4195): we no longer restart it, so a frozen
|
||||
@@ -3333,15 +3539,15 @@ Readonly<{delegatedAtTs: bigint; earliestFireTs: bigint}>
|
||||
readonly [uniffiTypeNameSymbol] = "FfiKeepAlive";
|
||||
readonly tag = FfiKeepAlive_Tags.Delegated;
|
||||
readonly inner:
|
||||
Readonly<{delegatedAtTs: bigint; earliestFireTs: bigint}>;
|
||||
Readonly<{delegatedAtTs: bigint; earliestFireTs: bigint; via: FfiDelegationRoute}>;
|
||||
constructor(
|
||||
inner: {delegatedAtTs: bigint; earliestFireTs: bigint }) {
|
||||
inner: {delegatedAtTs: bigint; earliestFireTs: bigint; via: FfiDelegationRoute }) {
|
||||
super("FfiKeepAlive", "Delegated");
|
||||
|
||||
this.inner = Object.freeze(inner);
|
||||
}
|
||||
static new(
|
||||
inner: {delegatedAtTs: bigint; earliestFireTs: bigint }): Delegated_ {
|
||||
inner: {delegatedAtTs: bigint; earliestFireTs: bigint; via: FfiDelegationRoute }): Delegated_ {
|
||||
return new Delegated_(inner);
|
||||
}
|
||||
|
||||
@@ -3485,7 +3691,7 @@ const FfiConverterTypeFfiKeepAlive = (() => {
|
||||
readFromCursor(c: Cursor): TypeName {
|
||||
switch (c.readI32()) {
|
||||
case 1: return new FfiKeepAlive.Armed({delayMs: FfiConverterUInt64.readFromCursor(c), lastRestartTs: FfiConverterUInt64.readFromCursor(c), firesAtTs: FfiConverterUInt64.readFromCursor(c) });
|
||||
case 2: return new FfiKeepAlive.Delegated({delegatedAtTs: FfiConverterUInt64.readFromCursor(c), earliestFireTs: FfiConverterUInt64.readFromCursor(c) });
|
||||
case 2: return new FfiKeepAlive.Delegated({delegatedAtTs: FfiConverterUInt64.readFromCursor(c), earliestFireTs: FfiConverterUInt64.readFromCursor(c), via: FfiConverterTypeFfiDelegationRoute.readFromCursor(c) });
|
||||
case 3: return new FfiKeepAlive.RestartFailing({sinceTs: FfiConverterUInt64.readFromCursor(c), firesAtTs: FfiConverterUInt64.readFromCursor(c), lastError: FfiConverterString.readFromCursor(c) });
|
||||
case 4: return new FfiKeepAlive.Expired({sinceTs: FfiConverterUInt64.readFromCursor(c) });
|
||||
case 5: return new FfiKeepAlive.Unavailable({permanent: FfiConverterBool.readFromCursor(c), nextProbeTs: FfiConverterOptionalUInt64.readFromCursor(c) });
|
||||
@@ -3507,6 +3713,7 @@ const FfiConverterTypeFfiKeepAlive = (() => {
|
||||
const inner = value.inner;
|
||||
FfiConverterUInt64.writeIntoCursor(inner.delegatedAtTs, c);
|
||||
FfiConverterUInt64.writeIntoCursor(inner.earliestFireTs, c);
|
||||
FfiConverterTypeFfiDelegationRoute.writeIntoCursor(inner.via, c);
|
||||
return;
|
||||
}
|
||||
case FfiKeepAlive_Tags.RestartFailing: {
|
||||
@@ -3550,6 +3757,7 @@ const FfiConverterTypeFfiKeepAlive = (() => {
|
||||
let size = 4;
|
||||
size += FfiConverterUInt64.allocationSize(inner.delegatedAtTs);
|
||||
size += FfiConverterUInt64.allocationSize(inner.earliestFireTs);
|
||||
size += FfiConverterTypeFfiDelegationRoute.allocationSize(inner.via);
|
||||
return size;
|
||||
}
|
||||
case FfiKeepAlive_Tags.RestartFailing: {
|
||||
@@ -5928,6 +6136,171 @@ const uniffiCallbackInterfaceMembershipsListener: { vtable: any; register: () =>
|
||||
},
|
||||
};
|
||||
|
||||
export interface SessionListener {
|
||||
|
||||
/**
|
||||
* The room's view of the session changed (seed done, slot opened or
|
||||
* closed, roster moved): what `session()` answers now.
|
||||
*/
|
||||
onSessionChange(session: FfiSessionSnapshot): void;
|
||||
}
|
||||
|
||||
|
||||
export class SessionListenerImpl extends UniffiAbstractObject implements SessionListener {
|
||||
|
||||
readonly [uniffiTypeNameSymbol] = "SessionListenerImpl";
|
||||
readonly [destructorGuardSymbol]: UniffiGcObject;
|
||||
readonly [pointerLiteralSymbol]: UniffiHandle;
|
||||
// No primary constructor declared for this class.
|
||||
private constructor(pointer: UniffiHandle) {
|
||||
super();
|
||||
this[pointerLiteralSymbol] = pointer;
|
||||
this[destructorGuardSymbol] = uniffiTypeSessionListenerImplObjectFactory.bless(pointer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* The room's view of the session changed (seed done, slot opened or
|
||||
* closed, roster moved): what `session()` answers now.
|
||||
*/
|
||||
onSessionChange(session: FfiSessionSnapshot): void {uniffiCaller.rustCall(
|
||||
/*caller:*/ (callStatus) => { nativeModule().ubrn_uniffi_matrix_rtc_fn_method_sessionlistener_on_session_change(
|
||||
uniffiTypeSessionListenerImplObjectFactory.clonePointer(this),
|
||||
FfiConverterTypeFfiSessionSnapshot.lower(session, nativeModule().rustbuffer_alloc),
|
||||
callStatus);
|
||||
},
|
||||
/*liftString:*/ FfiConverterString.lift.bind(FfiConverterString),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
uniffiDestroy(): void {
|
||||
const ptr = (this as any)[destructorGuardSymbol];
|
||||
if (ptr !== undefined) {
|
||||
const pointer = uniffiTypeSessionListenerImplObjectFactory.pointer(this);
|
||||
uniffiTypeSessionListenerImplObjectFactory.freePointer(pointer);
|
||||
uniffiTypeSessionListenerImplObjectFactory.unbless(ptr);
|
||||
delete (this as any)[destructorGuardSymbol];
|
||||
}
|
||||
}
|
||||
|
||||
static instanceOf(obj_: any): obj_ is SessionListenerImpl {
|
||||
return uniffiTypeSessionListenerImplObjectFactory.isConcreteType(obj_);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
const uniffiTypeSessionListenerImplObjectFactory: UniffiObjectFactory<SessionListener> = (() => {
|
||||
|
||||
/// <reference lib="es2021" />
|
||||
const registry = typeof FinalizationRegistry !== 'undefined' ? new FinalizationRegistry<UniffiHandle>((heldValue: UniffiHandle) => {
|
||||
uniffiTypeSessionListenerImplObjectFactory.freePointer(heldValue);
|
||||
}) : null;
|
||||
|
||||
return {
|
||||
create(pointer: UniffiHandle): SessionListener {
|
||||
const instance = Object.create(SessionListenerImpl.prototype);
|
||||
instance[pointerLiteralSymbol] = pointer;
|
||||
instance[destructorGuardSymbol] = this.bless(pointer);
|
||||
instance[uniffiTypeNameSymbol] = "SessionListenerImpl";
|
||||
return instance;
|
||||
},
|
||||
|
||||
|
||||
bless(p: UniffiHandle): UniffiGcObject {
|
||||
const ptr = {
|
||||
p, // make sure this object doesn't get optimized away.
|
||||
markDestroyed: () => undefined,
|
||||
};
|
||||
if (registry) {
|
||||
registry.register(ptr, p, ptr);
|
||||
}
|
||||
return ptr;
|
||||
},
|
||||
|
||||
unbless(ptr_: UniffiGcObject) {
|
||||
if (registry) {
|
||||
registry.unregister(ptr_);
|
||||
}
|
||||
},
|
||||
|
||||
pointer(obj_: SessionListener): UniffiHandle {
|
||||
if ((obj_ as any)[destructorGuardSymbol] === undefined) {
|
||||
throw new UniffiInternalError.UnexpectedNullPointer();
|
||||
}
|
||||
return (obj_ as any)[pointerLiteralSymbol];
|
||||
},
|
||||
|
||||
clonePointer(obj_: SessionListener): UniffiHandle {
|
||||
const pointer = this.pointer(obj_);
|
||||
return uniffiCaller.rustCall(
|
||||
/*caller:*/ (callStatus) => nativeModule().ubrn_uniffi_matrix_rtc_fn_clone_sessionlistener(pointer, callStatus),
|
||||
/*liftString:*/ FfiConverterString.lift
|
||||
);
|
||||
},
|
||||
|
||||
freePointer(pointer: UniffiHandle): void {
|
||||
uniffiCaller.rustCall(
|
||||
/*caller:*/ (callStatus) => nativeModule().ubrn_uniffi_matrix_rtc_fn_free_sessionlistener(pointer, callStatus),
|
||||
/*liftString:*/ FfiConverterString.lift
|
||||
);
|
||||
},
|
||||
|
||||
isConcreteType(obj_: any): obj_ is SessionListener {
|
||||
return obj_[destructorGuardSymbol] && obj_[uniffiTypeNameSymbol] === "SessionListenerImpl";
|
||||
},
|
||||
}})();
|
||||
const FfiConverterTypeSessionListener = new FfiConverterObjectWithCallbacks(uniffiTypeSessionListenerImplObjectFactory);
|
||||
|
||||
// Add a vtable for the callbacks that go in SessionListener.
|
||||
|
||||
// Put the implementation in a struct so we don't pollute the top-level namespace
|
||||
const uniffiCallbackInterfaceSessionListener: { vtable: any; register: () => void; } = {
|
||||
// Create the VTable using a series of closures.
|
||||
// ts automatically converts these into C callback functions.
|
||||
vtable: {
|
||||
on_session_change: (
|
||||
uniffiHandle: bigint,
|
||||
session: Uint8Array,) => {
|
||||
const uniffiMakeCall =
|
||||
()
|
||||
: void => {
|
||||
const jsCallback = FfiConverterTypeSessionListener.lift(uniffiHandle);
|
||||
return jsCallback.onSessionChange(
|
||||
FfiConverterTypeFfiSessionSnapshot.lift(session)
|
||||
)
|
||||
};
|
||||
const uniffiResult = UniffiResult.ready<void>();
|
||||
const uniffiHandleSuccess = (obj: any) => {};
|
||||
const uniffiHandleError = (code: number, errBuf: UniffiByteArray) => {
|
||||
UniffiResult.writeError(uniffiResult, code, errBuf);
|
||||
};
|
||||
uniffiTraitInterfaceCall(
|
||||
/*makeCall:*/ uniffiMakeCall,
|
||||
/*handleSuccess:*/ uniffiHandleSuccess,
|
||||
/*handleError:*/ uniffiHandleError,
|
||||
/*lowerString:*/ FfiConverterString.lower.bind(FfiConverterString),
|
||||
/*alloc:*/ nativeModule().rustbuffer_alloc,
|
||||
)
|
||||
return uniffiResult;
|
||||
},
|
||||
uniffi_free: (uniffiHandle: UniffiHandle): void => {
|
||||
// this will throw a stale handle error if the handle isn't found.
|
||||
FfiConverterTypeSessionListener.drop(uniffiHandle);
|
||||
},
|
||||
uniffi_clone: (uniffiHandle: UniffiHandle): UniffiHandle => {
|
||||
return FfiConverterTypeSessionListener.clone(uniffiHandle);
|
||||
}
|
||||
},
|
||||
register: () => {nativeModule().ubrn_uniffi_matrix_rtc_fn_init_callback_vtable_sessionlistener(
|
||||
uniffiCallbackInterfaceSessionListener.vtable
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export interface StatusListener {
|
||||
|
||||
onStatusChange(status: FfiStatus): void;
|
||||
@@ -6150,6 +6523,7 @@ export interface FfiParticipationManagerLike {
|
||||
setKeyMapListener(listener: KeyMapListener): void;
|
||||
setKeyRejectedListener(listener: KeyRejectedListener): void;
|
||||
setMembershipsListener(listener: MembershipsListener): void;
|
||||
setSessionListener(listener: SessionListener): void;
|
||||
setStatusListener(listener: StatusListener): void;
|
||||
status(): FfiStatus;
|
||||
/**
|
||||
@@ -6513,6 +6887,16 @@ export class FfiParticipationManager extends UniffiAbstractObject implements Ffi
|
||||
);
|
||||
}
|
||||
|
||||
setSessionListener(listener: SessionListener): void {uniffiCaller.rustCall(
|
||||
/*caller:*/ (callStatus) => { nativeModule().ubrn_uniffi_matrix_rtc_fn_method_ffiparticipationmanager_set_session_listener(
|
||||
uniffiTypeFfiParticipationManagerObjectFactory.clonePointer(this),
|
||||
FfiConverterTypeSessionListener.lower(listener, nativeModule().rustbuffer_alloc),
|
||||
callStatus);
|
||||
},
|
||||
/*liftString:*/ FfiConverterString.lift.bind(FfiConverterString),
|
||||
);
|
||||
}
|
||||
|
||||
setStatusListener(listener: StatusListener): void {uniffiCaller.rustCall(
|
||||
/*caller:*/ (callStatus) => { nativeModule().ubrn_uniffi_matrix_rtc_fn_method_ffiparticipationmanager_set_status_listener(
|
||||
uniffiTypeFfiParticipationManagerObjectFactory.clonePointer(this),
|
||||
@@ -7082,8 +7466,21 @@ export interface MatrixDriverCallback {
|
||||
* the transport we publish on (`None` for a receive-only member) and
|
||||
* `delay_ms` the armed delay — what an adapter that delegates through
|
||||
* the authorisation service's token endpoint needs.
|
||||
* MSC4195 via the homeserver: one authenticated
|
||||
* `POST /_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`
|
||||
* with `{ url, room_id, slot_id, member, delay_id, delay_timeout }` (see
|
||||
* `FfiHomeserverDelegationRequest`). A client that cannot make
|
||||
* authenticated homeserver calls (a widget) throws `Unsupported`; the
|
||||
* crate then tries the authorisation service.
|
||||
*/
|
||||
delegateLivekitDelayedLeave(roomId: string, slotId: string, memberJson: string, delayId: string, livekitServiceUrl: string | undefined, delayMs: bigint, asyncOpts_?: { signal: AbortSignal }) /*throws*/: Promise<void>;
|
||||
delegateDelayedLeaveViaHomeserver(request: FfiHomeserverDelegationRequest, asyncOpts_?: { signal: AbortSignal }) /*throws*/: Promise<void>;
|
||||
/**
|
||||
* MSC4195 via the authorisation service: the `get_token` (or, with
|
||||
* `legacy_sfu_get`, `sfu/get`) request the adapter already makes, with
|
||||
* `delay_id`, `delay_timeout` (= `delay_timeout_ms`) and the adapter's
|
||||
* own CS API URL (`delay_cs_api_url`) added. Discard the token.
|
||||
*/
|
||||
delegateDelayedLeaveViaTransport(request: FfiTransportDelegationRequest, asyncOpts_?: { signal: AbortSignal }) /*throws*/: Promise<void>;
|
||||
sendToDevice(recipients: Array<FfiToDeviceRecipient>, eventType: string, contentJson: string, asyncOpts_?: { signal: AbortSignal }) /*throws*/: Promise<Array<FfiToDeviceDelivery>>;
|
||||
/**
|
||||
* `GET /_matrix/client/v1/rtc/transports`, with well-known fallback.
|
||||
@@ -7340,13 +7737,44 @@ private constructor(pointer: UniffiHandle) {
|
||||
* the transport we publish on (`None` for a receive-only member) and
|
||||
* `delay_ms` the armed delay — what an adapter that delegates through
|
||||
* the authorisation service's token endpoint needs.
|
||||
* MSC4195 via the homeserver: one authenticated
|
||||
* `POST /_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`
|
||||
* with `{ url, room_id, slot_id, member, delay_id, delay_timeout }` (see
|
||||
* `FfiHomeserverDelegationRequest`). A client that cannot make
|
||||
* authenticated homeserver calls (a widget) throws `Unsupported`; the
|
||||
* crate then tries the authorisation service.
|
||||
*/
|
||||
async delegateLivekitDelayedLeave(roomId: string, slotId: string, memberJson: string, delayId: string, livekitServiceUrl: string | undefined, delayMs: bigint, asyncOpts_?: { signal: AbortSignal }): Promise<void> /*throws*/ {
|
||||
async delegateDelayedLeaveViaHomeserver(request: FfiHomeserverDelegationRequest, asyncOpts_?: { signal: AbortSignal }): Promise<void> /*throws*/ {
|
||||
return await uniffiRustCallAsync(
|
||||
/*rustCaller:*/ uniffiCaller,
|
||||
/*rustFutureFunc:*/ () => {
|
||||
return nativeModule().ubrn_uniffi_matrix_rtc_fn_method_matrixdrivercallback_delegate_livekit_delayed_leave(
|
||||
uniffiTypeMatrixDriverCallbackImplObjectFactory.clonePointer(this),FfiConverterString.lower(roomId, nativeModule().rustbuffer_alloc),FfiConverterString.lower(slotId, nativeModule().rustbuffer_alloc),FfiConverterString.lower(memberJson, nativeModule().rustbuffer_alloc),FfiConverterString.lower(delayId, nativeModule().rustbuffer_alloc),FfiConverterOptionalString.lower(livekitServiceUrl, nativeModule().rustbuffer_alloc),FfiConverterUInt64.lower(delayMs, nativeModule().rustbuffer_alloc)
|
||||
return nativeModule().ubrn_uniffi_matrix_rtc_fn_method_matrixdrivercallback_delegate_delayed_leave_via_homeserver(
|
||||
uniffiTypeMatrixDriverCallbackImplObjectFactory.clonePointer(this),FfiConverterTypeFfiHomeserverDelegationRequest.lower(request, nativeModule().rustbuffer_alloc)
|
||||
);
|
||||
},
|
||||
/*pollFunc:*/ nativeModule().ubrn_ffi_matrix_rtc_rust_future_poll_void,
|
||||
/*cancelFunc:*/ nativeModule().ubrn_ffi_matrix_rtc_rust_future_cancel_void,
|
||||
/*completeFunc:*/ nativeModule().ubrn_ffi_matrix_rtc_rust_future_complete_void,
|
||||
/*freeFunc:*/ nativeModule().ubrn_ffi_matrix_rtc_rust_future_free_void,
|
||||
/*liftFunc:*/ (_v) => {},
|
||||
/*liftString:*/ FfiConverterString.lift.bind(FfiConverterString),
|
||||
/*asyncOpts:*/ asyncOpts_,
|
||||
/*errorHandler:*/ FfiConverterTypeRtcError.lift.bind(FfiConverterTypeRtcError)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MSC4195 via the authorisation service: the `get_token` (or, with
|
||||
* `legacy_sfu_get`, `sfu/get`) request the adapter already makes, with
|
||||
* `delay_id`, `delay_timeout` (= `delay_timeout_ms`) and the adapter's
|
||||
* own CS API URL (`delay_cs_api_url`) added. Discard the token.
|
||||
*/
|
||||
async delegateDelayedLeaveViaTransport(request: FfiTransportDelegationRequest, asyncOpts_?: { signal: AbortSignal }): Promise<void> /*throws*/ {
|
||||
return await uniffiRustCallAsync(
|
||||
/*rustCaller:*/ uniffiCaller,
|
||||
/*rustFutureFunc:*/ () => {
|
||||
return nativeModule().ubrn_uniffi_matrix_rtc_fn_method_matrixdrivercallback_delegate_delayed_leave_via_transport(
|
||||
uniffiTypeMatrixDriverCallbackImplObjectFactory.clonePointer(this),FfiConverterTypeFfiTransportDelegationRequest.lower(request, nativeModule().rustbuffer_alloc)
|
||||
);
|
||||
},
|
||||
/*pollFunc:*/ nativeModule().ubrn_ffi_matrix_rtc_rust_future_poll_void,
|
||||
@@ -7992,27 +8420,60 @@ const uniffiCallbackInterfaceMatrixDriverCallback: { vtable: any; register: () =
|
||||
);
|
||||
return uniffiForeignFuture;
|
||||
},
|
||||
delegate_livekit_delayed_leave: (
|
||||
delegate_delayed_leave_via_homeserver: (
|
||||
uniffiHandle: bigint,
|
||||
roomId: Uint8Array,
|
||||
slotId: Uint8Array,
|
||||
memberJson: Uint8Array,
|
||||
delayId: Uint8Array,
|
||||
livekitServiceUrl: Uint8Array,
|
||||
delayMs: bigint,
|
||||
request: Uint8Array,
|
||||
uniffiFutureCallback: UniffiForeignFutureCompletevoid,
|
||||
uniffiCallbackData: bigint) => {
|
||||
const uniffiMakeCall =
|
||||
async (signal: AbortSignal)
|
||||
: Promise<void> => {
|
||||
const jsCallback = FfiConverterTypeMatrixDriverCallback.lift(uniffiHandle);
|
||||
return await jsCallback.delegateLivekitDelayedLeave(
|
||||
FfiConverterString.lift(roomId),
|
||||
FfiConverterString.lift(slotId),
|
||||
FfiConverterString.lift(memberJson),
|
||||
FfiConverterString.lift(delayId),
|
||||
FfiConverterOptionalString.lift(livekitServiceUrl),
|
||||
FfiConverterUInt64.lift(delayMs), { signal }
|
||||
return await jsCallback.delegateDelayedLeaveViaHomeserver(
|
||||
FfiConverterTypeFfiHomeserverDelegationRequest.lift(request), { signal }
|
||||
)
|
||||
};
|
||||
const uniffiHandleSuccess = (returnValue: void) => {
|
||||
uniffiFutureCallback.call(
|
||||
uniffiFutureCallback,
|
||||
uniffiCallbackData,
|
||||
/* UniffiForeignFutureResultVoid */{
|
||||
call_status: uniffiCaller.createCallStatus()
|
||||
}
|
||||
);
|
||||
};
|
||||
const uniffiHandleError = (code: number, errorBuf: UniffiByteArray) => {
|
||||
uniffiFutureCallback.call(
|
||||
uniffiFutureCallback,
|
||||
uniffiCallbackData,
|
||||
/* UniffiForeignFutureResultVoid */{
|
||||
// TODO create callstatus with error.
|
||||
call_status: uniffiCaller.createErrorStatus(code, errorBuf),
|
||||
}
|
||||
);
|
||||
};
|
||||
const uniffiForeignFuture = uniffiTraitInterfaceCallAsyncWithError(
|
||||
/*makeCall:*/ uniffiMakeCall,
|
||||
/*handleSuccess:*/ uniffiHandleSuccess,
|
||||
/*handleError:*/ uniffiHandleError,
|
||||
/*isErrorType:*/ RtcError.instanceOf,
|
||||
/*lowerError:*/ FfiConverterTypeRtcError.lower.bind(FfiConverterTypeRtcError),
|
||||
/*lowerString:*/ FfiConverterString.lower.bind(FfiConverterString),
|
||||
/*alloc:*/ nativeModule().rustbuffer_alloc,
|
||||
);
|
||||
return uniffiForeignFuture;
|
||||
},
|
||||
delegate_delayed_leave_via_transport: (
|
||||
uniffiHandle: bigint,
|
||||
request: Uint8Array,
|
||||
uniffiFutureCallback: UniffiForeignFutureCompletevoid,
|
||||
uniffiCallbackData: bigint) => {
|
||||
const uniffiMakeCall =
|
||||
async (signal: AbortSignal)
|
||||
: Promise<void> => {
|
||||
const jsCallback = FfiConverterTypeMatrixDriverCallback.lift(uniffiHandle);
|
||||
return await jsCallback.delegateDelayedLeaveViaTransport(
|
||||
FfiConverterTypeFfiTransportDelegationRequest.lift(request), { signal }
|
||||
)
|
||||
};
|
||||
const uniffiHandleSuccess = (returnValue: void) => {
|
||||
@@ -8435,12 +8896,12 @@ const FfiConverterSequenceTypeFfiMember = new FfiConverterArray(FfiConverterType
|
||||
// FfiConverter for FfiKeyRejection | undefined
|
||||
const FfiConverterOptionalTypeFfiKeyRejection = new FfiConverterOptional(FfiConverterTypeFfiKeyRejection);
|
||||
|
||||
// FfiConverter for FfiMediaKeyState | undefined
|
||||
const FfiConverterOptionalTypeFfiMediaKeyState = new FfiConverterOptional(FfiConverterTypeFfiMediaKeyState);
|
||||
|
||||
// FfiConverter for boolean | undefined
|
||||
const FfiConverterOptionalBoolean = new FfiConverterOptional(FfiConverterBool);
|
||||
|
||||
// FfiConverter for FfiMediaKeyState | undefined
|
||||
const FfiConverterOptionalTypeFfiMediaKeyState = new FfiConverterOptional(FfiConverterTypeFfiMediaKeyState);
|
||||
|
||||
// FfiConverter for Array<FfiSessionRead>
|
||||
const FfiConverterSequenceTypeFfiSessionRead = new FfiConverterArray(FfiConverterTypeFfiSessionRead);
|
||||
|
||||
@@ -8568,6 +9029,9 @@ function uniffiEnsureInitialized() {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_ffiparticipationmanager_set_memberships_listener() !== 46727) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_ffiparticipationmanager_set_memberships_listener");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_ffiparticipationmanager_set_session_listener() !== 784) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_ffiparticipationmanager_set_session_listener");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_ffiparticipationmanager_set_status_listener() !== 8439) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_ffiparticipationmanager_set_status_listener");
|
||||
}
|
||||
@@ -8601,37 +9065,40 @@ function uniffiEnsureInitialized() {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_cancel_delayed_event() !== 48021) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_cancel_delayed_event");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_delegate_livekit_delayed_leave() !== 31950) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_delegate_livekit_delayed_leave");
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_delegate_delayed_leave_via_homeserver() !== 43539) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_delegate_delayed_leave_via_homeserver");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_send_to_device() !== 29274) {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_delegate_delayed_leave_via_transport() !== 6420) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_delegate_delayed_leave_via_transport");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_send_to_device() !== 43316) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_send_to_device");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_get_rtc_transports() !== 55674) {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_get_rtc_transports() !== 33640) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_get_rtc_transports");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_get_livekit_token() !== 36238) {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_get_livekit_token() !== 25518) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_get_livekit_token");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_read_events() !== 18104) {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_read_events() !== 28203) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_read_events");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_read_state() !== 58428) {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_read_state() !== 63481) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_read_state");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_room_events() !== 31129) {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_room_events() !== 8803) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_room_events");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_to_device_events() !== 55587) {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_to_device_events() !== 45060) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_to_device_events");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_state_updates() !== 53493) {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_state_updates() !== 58784) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_state_updates");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_is_homeserver_connected() !== 28631) {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_is_homeserver_connected() !== 56819) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_is_homeserver_connected");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_connectivity() !== 46098) {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_connectivity() !== 55109) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_subscribe_connectivity");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_membershipslistener_on_memberships_change() !== 29319) {
|
||||
@@ -8640,6 +9107,9 @@ function uniffiEnsureInitialized() {
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_roomeventsink_emit() !== 34604) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_roomeventsink_emit");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_sessionlistener_on_session_change() !== 30825) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_sessionlistener_on_session_change");
|
||||
}
|
||||
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_stateupdatesink_emit() !== 7988) {
|
||||
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_stateupdatesink_emit");
|
||||
}
|
||||
@@ -8654,6 +9124,7 @@ function uniffiEnsureInitialized() {
|
||||
uniffiCallbackInterfaceKeyMapListener.register();
|
||||
uniffiCallbackInterfaceKeyRejectedListener.register();
|
||||
uniffiCallbackInterfaceMembershipsListener.register();
|
||||
uniffiCallbackInterfaceSessionListener.register();
|
||||
uniffiCallbackInterfaceStatusListener.register();
|
||||
uniffiCallbackInterfaceMatrixDriverCallback.register();
|
||||
}
|
||||
@@ -8669,12 +9140,14 @@ export default Object.freeze({
|
||||
FfiConverterTypeFfiConnectionProblemKind,
|
||||
FfiConverterTypeFfiConnectionWithMembers,
|
||||
FfiConverterTypeFfiDelayedLeaveOutcome,
|
||||
FfiConverterTypeFfiDelegationRoute,
|
||||
FfiConverterTypeFfiDeviceAttribution,
|
||||
FfiConverterTypeFfiDisconnectCause,
|
||||
FfiConverterTypeFfiElementCallCompat,
|
||||
FfiConverterTypeFfiEncryptionStatus,
|
||||
FfiConverterTypeFfiEventOrigin,
|
||||
FfiConverterTypeFfiExcludedCandidate,
|
||||
FfiConverterTypeFfiHomeserverDelegationRequest,
|
||||
FfiConverterTypeFfiImpairment,
|
||||
FfiConverterTypeFfiJoinError,
|
||||
FfiConverterTypeFfiJoinExclusionReason,
|
||||
@@ -8702,6 +9175,7 @@ export default Object.freeze({
|
||||
FfiConverterTypeFfiStatus,
|
||||
FfiConverterTypeFfiToDeviceDelivery,
|
||||
FfiConverterTypeFfiToDeviceRecipient,
|
||||
FfiConverterTypeFfiTransportDelegationRequest,
|
||||
FfiConverterTypeFfiTransportIntent,
|
||||
FfiConverterTypeKeyMapListener,
|
||||
FfiConverterTypeKeyRejectedListener,
|
||||
@@ -8709,6 +9183,7 @@ export default Object.freeze({
|
||||
FfiConverterTypeMembershipsListener,
|
||||
FfiConverterTypeRoomEventSink,
|
||||
FfiConverterTypeRtcError,
|
||||
FfiConverterTypeSessionListener,
|
||||
FfiConverterTypeStateUpdateSink,
|
||||
FfiConverterTypeStatusListener,
|
||||
FfiConverterTypeToDeviceSink,
|
||||
|
||||
+740
-641
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -53,7 +53,7 @@ describe("matrix-rtc-sdk", () => {
|
||||
});
|
||||
const [session] = computeSessionsFromEvents(
|
||||
[join],
|
||||
FfiElementCallCompat.StickyEvents,
|
||||
FfiElementCallCompat.Off,
|
||||
);
|
||||
expect(session.memberCount).toBe(1);
|
||||
expect(session.members[0].eventId).toBe("$1");
|
||||
@@ -69,7 +69,7 @@ describe("matrix-rtc-sdk", () => {
|
||||
"MYDEV",
|
||||
driver,
|
||||
{
|
||||
compat: FfiElementCallCompat.StickyEvents,
|
||||
compat: FfiElementCallCompat.Off,
|
||||
manageMediaKeys: false,
|
||||
requireCrossSignedSender: false,
|
||||
useKeyDelayMs: 1000n,
|
||||
@@ -102,7 +102,10 @@ class InertDriver implements MatrixDriverCallback {
|
||||
public async cancelDelayedEvent(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
public async delegateLivekitDelayedLeave(): Promise<void> {
|
||||
public async delegateDelayedLeaveViaHomeserver(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
public async delegateDelayedLeaveViaTransport(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
public async sendToDevice(
|
||||
|
||||
@@ -25,7 +25,10 @@ export {
|
||||
FfiElementCallCompat,
|
||||
FfiEventOrigin,
|
||||
FfiStatus,
|
||||
FfiDelegationRoute,
|
||||
FfiDeviceAttribution,
|
||||
FfiDisconnectCause,
|
||||
FfiImpairment,
|
||||
FfiJoinError,
|
||||
FfiKeepAlive,
|
||||
FfiMembershipState,
|
||||
@@ -36,6 +39,7 @@ export {
|
||||
export type {
|
||||
FfiConnectionData,
|
||||
FfiConnectionWithMembers,
|
||||
FfiHomeserverDelegationRequest,
|
||||
FfiJoinParams,
|
||||
FfiLivekitToken,
|
||||
FfiLivekitTokenRequest,
|
||||
@@ -48,6 +52,7 @@ export type {
|
||||
FfiSessionSnapshot,
|
||||
FfiToDeviceDelivery,
|
||||
FfiToDeviceRecipient,
|
||||
FfiTransportDelegationRequest,
|
||||
ConnectivitySinkLike,
|
||||
MatrixDriverCallback,
|
||||
RoomEventSinkLike,
|
||||
|
||||
@@ -44,7 +44,7 @@ import { InviteButton } from "../button/InviteButton";
|
||||
import {
|
||||
type CallViewModel,
|
||||
callViewModelOptionsFromParams,
|
||||
createCallViewModel$,
|
||||
createJsClientCallViewModel$,
|
||||
} from "../state/CallViewModel/CallViewModel.ts";
|
||||
import { Grid, type TileProps } from "../grid/Grid";
|
||||
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||
@@ -132,7 +132,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
||||
urlParams;
|
||||
|
||||
const vm = createCallViewModel$(
|
||||
const vm = createJsClientCallViewModel$(
|
||||
scope,
|
||||
props.rtcSession,
|
||||
props.matrixRoom,
|
||||
|
||||
@@ -6,12 +6,6 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test } from "vitest";
|
||||
import {
|
||||
EventType,
|
||||
type IRoomTimelineData,
|
||||
MatrixEvent,
|
||||
type Room,
|
||||
} from "matrix-js-sdk";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
|
||||
|
||||
@@ -182,22 +176,10 @@ test("ring attempt can be declined", () => {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot("--d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
sender: alice.userId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$notif1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
d: {
|
||||
sender: alice.userId,
|
||||
relatesTo: { relType: "m.reference", eventId: "$notif1" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -217,22 +199,10 @@ test("ring attempt times out if recipient declines too late", () => {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot("100ms d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
sender: alice.userId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$notif1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
d: {
|
||||
sender: alice.userId,
|
||||
relatesTo: { relType: "m.reference", eventId: "$notif1" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -252,22 +222,13 @@ test("decline event relating to wrong event is ignored (times out)", () => {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot("--d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
sender: alice.userId,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$other", // <---- WRONG
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
d: {
|
||||
sender: alice.userId,
|
||||
relatesTo: {
|
||||
relType: "m.reference",
|
||||
eventId: "$other", // <---- WRONG
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -287,22 +248,13 @@ test("decline event from wrong sender is ignored (times out)", () => {
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot("--d", {
|
||||
d: [
|
||||
new MatrixEvent({
|
||||
type: EventType.RTCDecline,
|
||||
sender: local.userId, // <---- WRONG
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: "m.reference",
|
||||
event_id: "$notif1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{} as Room,
|
||||
undefined,
|
||||
false,
|
||||
{} as IRoomTimelineData,
|
||||
],
|
||||
d: {
|
||||
sender: local.userId, // <---- WRONG
|
||||
relatesTo: {
|
||||
relType: "m.reference",
|
||||
eventId: "$notif1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type Epoch, type ObservableScope } from "../ObservableScope";
|
||||
import { type RoomMemberMap } from "./remoteMembers/MatrixMemberMetadata";
|
||||
|
||||
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
|
||||
|
||||
@@ -69,24 +68,42 @@ export function createSentCallNotification$(
|
||||
return sentCallNotification$;
|
||||
}
|
||||
|
||||
/** A received `org.matrix.msc4310.rtc.decline`, as far as ringing cares. */
|
||||
export interface DeclineEvent {
|
||||
sender: string;
|
||||
/** The `m.relates_to` of the event, when it has one. */
|
||||
relatesTo: { relType?: string; eventId?: string } | undefined;
|
||||
}
|
||||
|
||||
export function createReceivedDecline$(
|
||||
matrixRoom: MatrixRoom,
|
||||
): Observable<Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>> {
|
||||
): Observable<DeclineEvent> {
|
||||
return (
|
||||
fromEvent(matrixRoom, RoomEvent.Timeline) as Observable<
|
||||
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
|
||||
>
|
||||
).pipe(filter(([event]) => event.getType() === EventType.RTCDecline));
|
||||
).pipe(
|
||||
filter(([event]) => event.getType() === EventType.RTCDecline),
|
||||
map(([event]) => {
|
||||
const relation = event.getRelation();
|
||||
return {
|
||||
sender: event.getSender() ?? "",
|
||||
relatesTo: relation
|
||||
? { relType: relation.rel_type, eventId: relation.event_id }
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
scope: ObservableScope;
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>;
|
||||
matrixRoomMembers$: Behavior<RoomMemberMap>;
|
||||
/** The call's members; only who they are matters here. */
|
||||
memberships$: Behavior<Epoch<Pick<CallMembership, "userId">[]>>;
|
||||
/** The room's members; only who they are matters here. */
|
||||
matrixRoomMembers$: Behavior<ReadonlyMap<string, unknown>>;
|
||||
sentCallNotification$: Observable<CallNotificationWrapper | null>;
|
||||
receivedDecline$: Observable<
|
||||
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
|
||||
>;
|
||||
receivedDecline$: Observable<DeclineEvent>;
|
||||
options: { waitForCallPickup?: boolean; autoLeaveWhenOthersLeft?: boolean };
|
||||
localUser: { deviceId: string; userId: string };
|
||||
}
|
||||
@@ -147,10 +164,10 @@ export function createCallNotificationLifecycle$({
|
||||
// Call is declined when we receive a decline event
|
||||
const decline$ = receivedDecline$.pipe(
|
||||
filter(
|
||||
([event]) =>
|
||||
event.getRelation()?.rel_type === "m.reference" &&
|
||||
event.getRelation()?.event_id === notificationEvent.event_id &&
|
||||
event.getSender() === recipient,
|
||||
(event) =>
|
||||
event.relatesTo?.relType === "m.reference" &&
|
||||
event.relatesTo.eventId === notificationEvent.event_id &&
|
||||
event.sender === recipient,
|
||||
),
|
||||
map(() => "decline" as const),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `createCallViewModel$` over a real `CallParticipation` (the crate, through
|
||||
* the mock drivers) and mocked LiveKit connections: the Matrix side end to
|
||||
* end, from the user's join to the roster and back out.
|
||||
*
|
||||
* Real wasm, real timers — nothing here may use `vi.useFakeTimers`.
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
|
||||
import { MatrixRTCMode } from "../../config/ConfigOptions";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
import { MockElementCallMatrixClientDriver } from "../../driver/MockElementCallMatrixClientDriver";
|
||||
import {
|
||||
MOCK_LK_SERVICE_URL,
|
||||
MockRtcMatrixDriver,
|
||||
slotEvent,
|
||||
waitFor,
|
||||
} from "../../driver/MockRtcMatrixDriver";
|
||||
import { FfiStatus } from "../../matrix-rtc-sdk";
|
||||
import { type RaisedHandInfo, type ReactionInfo } from "../../reactions";
|
||||
import { MatrixRTCTransportMissingError } from "../../utils/errors";
|
||||
import {
|
||||
MockConnection,
|
||||
mockConfig,
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockMuteStates,
|
||||
testScope,
|
||||
} from "../../utils/test";
|
||||
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
|
||||
import { constant } from "../Behavior";
|
||||
import { CallParticipation } from "../rtc/CallParticipation";
|
||||
import { joinParamsFromConfig, participationConfig } from "../rtc/joinParams";
|
||||
import { type CallViewModel, createCallViewModel$ } from "./CallViewModel";
|
||||
|
||||
mockConfig({});
|
||||
|
||||
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 peer = {
|
||||
userId: "@peer:example.org",
|
||||
deviceId: "PEERDEV",
|
||||
memberId: "m-peer",
|
||||
};
|
||||
|
||||
function createEnvironment(driver: MockRtcMatrixDriver): {
|
||||
vm: CallViewModel;
|
||||
participation: CallParticipation;
|
||||
clientDriver: MockElementCallMatrixClientDriver;
|
||||
} {
|
||||
const scope = testScope();
|
||||
const clientDriver = new MockElementCallMatrixClientDriver({
|
||||
userId: driver.userId,
|
||||
deviceId: driver.deviceId,
|
||||
roomId: driver.roomId,
|
||||
members: [
|
||||
{
|
||||
userId: driver.userId,
|
||||
displayName: "Me",
|
||||
avatarUrl: null,
|
||||
membership: "join",
|
||||
},
|
||||
{
|
||||
userId: peer.userId,
|
||||
displayName: "Peer",
|
||||
avatarUrl: null,
|
||||
membership: "join",
|
||||
},
|
||||
],
|
||||
});
|
||||
const participation = new CallParticipation(
|
||||
scope,
|
||||
driver,
|
||||
driver.roomId,
|
||||
driver.userId,
|
||||
driver.deviceId,
|
||||
{
|
||||
config: participationConfig({
|
||||
mode: MatrixRTCMode.Matrix_2_0,
|
||||
manageMediaKeys: false,
|
||||
session,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const livekitRoomFactory = (): LivekitRoom =>
|
||||
mockLivekitRoom({
|
||||
localParticipant: mockLocalParticipant({ identity: "" }),
|
||||
remoteParticipants: new Map(),
|
||||
disconnect: async () => Promise.resolve(),
|
||||
setE2EEEnabled: async () => Promise.resolve(),
|
||||
});
|
||||
const vm = createCallViewModel$(
|
||||
scope,
|
||||
participation,
|
||||
clientDriver,
|
||||
mockMediaDevices({}),
|
||||
mockMuteStates(),
|
||||
{
|
||||
encryptionSystem: { kind: E2eeType.NONE },
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
livekitRoomFactory,
|
||||
connectionFactory: {
|
||||
createConnection(scope, transport, ownMembershipIdentity, logger, sfu) {
|
||||
return new MockConnection(
|
||||
{
|
||||
scope,
|
||||
transport,
|
||||
ownMembershipIdentity,
|
||||
existingSFUConfig: sfu,
|
||||
client: null,
|
||||
roomId: driver.roomId,
|
||||
livekitRoomFactory,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
},
|
||||
},
|
||||
windowSize$: constant({ width: 1000, height: 800 }),
|
||||
joinParams: joinParamsFromConfig({
|
||||
session,
|
||||
delegateDelayedLeave: false,
|
||||
}),
|
||||
},
|
||||
new BehaviorSubject<Record<string, RaisedHandInfo>>({}),
|
||||
new BehaviorSubject<Record<string, ReactionInfo>>({}),
|
||||
constant({ processor: undefined, supported: false }),
|
||||
);
|
||||
return { vm, participation, clientDriver };
|
||||
}
|
||||
|
||||
describe("createCallViewModel$ over a CallParticipation", () => {
|
||||
beforeAll(async () => {
|
||||
await initMatrixRtcSdkForTests();
|
||||
});
|
||||
|
||||
it("joins through the crate, lists the members and leaves again", async () => {
|
||||
// Somebody already started the call: the slot is open.
|
||||
const driver = new MockRtcMatrixDriver({
|
||||
roomState: [slotEvent({ status: "open" })],
|
||||
});
|
||||
const { vm, participation } = createEnvironment(driver);
|
||||
expect(vm.participantCount$.value).toBe(0);
|
||||
expect(vm.connected$.value).toBe(false);
|
||||
|
||||
vm.join();
|
||||
await waitFor("the crate to be connected", () =>
|
||||
FfiStatus.Connected.instanceOf(participation.status$.value),
|
||||
);
|
||||
// The crate discovered the transport and minted our token; the view
|
||||
// model holds a connection to it.
|
||||
await waitFor(
|
||||
"our connection",
|
||||
() => vm.allConnections$.value.getConnections().length === 1,
|
||||
);
|
||||
expect(
|
||||
vm.allConnections$.value.getConnections()[0].transport
|
||||
.livekit_service_url,
|
||||
).toBe(MOCK_LK_SERVICE_URL);
|
||||
// Our own membership echoed back: we are a member with our device.
|
||||
await waitFor(
|
||||
"our own tile",
|
||||
() => vm.localMatrixLivekitMember$.value !== null,
|
||||
);
|
||||
expect(vm.localMatrixLivekitMember$.value?.membership$.value).toMatchObject(
|
||||
{ userId: driver.userId, deviceId: driver.deviceId },
|
||||
);
|
||||
expect(vm.participantCount$.value).toBe(1);
|
||||
expect(vm.fatalError$.value).toBeNull();
|
||||
|
||||
driver.peerJoins(peer);
|
||||
await waitFor(
|
||||
"the peer's tile",
|
||||
() => vm.remoteMatrixLivekitMembers$.value.length === 1,
|
||||
);
|
||||
const [remote] = vm.remoteMatrixLivekitMembers$.value;
|
||||
expect(remote.membership$.value).toMatchObject({
|
||||
userId: peer.userId,
|
||||
memberId: peer.memberId,
|
||||
});
|
||||
// Both publish on the same service: one connection carries both.
|
||||
expect(remote.connection$.value?.transport.livekit_service_url).toBe(
|
||||
MOCK_LK_SERVICE_URL,
|
||||
);
|
||||
expect(vm.participantCount$.value).toBe(2);
|
||||
|
||||
vm.leave();
|
||||
await waitFor("the crate to be disconnected", () =>
|
||||
FfiStatus.Disconnected.instanceOf(participation.status$.value),
|
||||
);
|
||||
await waitFor(
|
||||
"our tile to go",
|
||||
() => vm.localMatrixLivekitMember$.value === null,
|
||||
);
|
||||
expect(vm.fatalError$.value).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the crate's join failure as the fatal error", async () => {
|
||||
// The homeserver advertises no transport and there is no fallback.
|
||||
const driver = new MockRtcMatrixDriver({
|
||||
roomState: [slotEvent({ status: "open" })],
|
||||
transports: [],
|
||||
});
|
||||
const { vm } = createEnvironment(driver);
|
||||
vm.join();
|
||||
await waitFor("the fatal error", () => vm.fatalError$.value !== null);
|
||||
expect(vm.fatalError$.value).toBeInstanceOf(MatrixRTCTransportMissingError);
|
||||
});
|
||||
});
|
||||
@@ -109,6 +109,7 @@ import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts
|
||||
import {
|
||||
createLocalMembership$,
|
||||
enterRTCSession,
|
||||
type LocalMembership,
|
||||
TransportState,
|
||||
} from "./localMember/LocalMember.ts";
|
||||
import {
|
||||
@@ -138,10 +139,13 @@ import {
|
||||
createCallNotificationLifecycle$,
|
||||
createReceivedDecline$,
|
||||
createSentCallNotification$,
|
||||
type RingAttempt,
|
||||
} from "./CallNotificationLifecycle.ts";
|
||||
import {
|
||||
createMatrixMemberMetadata$,
|
||||
createRoomMembers$,
|
||||
type MatrixMemberMetadata,
|
||||
type RoomMemberMap,
|
||||
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
||||
import { Publisher } from "./localMember/Publisher.ts";
|
||||
import { type Connection } from "./remoteMembers/Connection.ts";
|
||||
@@ -163,6 +167,25 @@ import {
|
||||
type RingingMediaViewModel,
|
||||
} from "../media/RingingMediaViewModel.ts";
|
||||
import { type GridTileViewModel } from "../TileViewModel.ts";
|
||||
import { mapEpoch } from "../ObservableScope.ts";
|
||||
import { type CallParticipation } from "../rtc/CallParticipation.ts";
|
||||
import { joinParamsFromConfig } from "../rtc/joinParams.ts";
|
||||
import { type FfiJoinParams } from "../../matrix-rtc-sdk";
|
||||
import { type ElementCallMatrixClientDriver } from "../../driver/ElementCallMatrixClientDriver.ts";
|
||||
import { observeDriver } from "../../driver/observe.ts";
|
||||
import { ParticipationKeyProvider } from "../../e2ee/participationKeyProvider.ts";
|
||||
import { customLivekitUrl } from "../../settings/settings.ts";
|
||||
import { createParticipationConnectionManager$ } from "./remoteMembers/ParticipationConnections.ts";
|
||||
import {
|
||||
callMemberOf,
|
||||
createParticipationRemoteMembers$,
|
||||
} from "./remoteMembers/ParticipationMembers.ts";
|
||||
import { createParticipationRoomMembers$ } from "./remoteMembers/ParticipationMemberMetadata.ts";
|
||||
import { createParticipationLocalMembership$ } from "./localMember/ParticipationLocalMember.ts";
|
||||
import {
|
||||
createParticipationReceivedDecline$,
|
||||
createParticipationSentCallNotification$,
|
||||
} from "./ParticipationCallNotification.ts";
|
||||
|
||||
//TODO
|
||||
// Larger rename
|
||||
@@ -222,6 +245,12 @@ export interface CallViewModelOptions {
|
||||
matrixRTCMode?: MatrixRTCMode;
|
||||
/** Optional behavior overriding for the screensharing, for testing */
|
||||
toggleScreensharing?: () => void;
|
||||
/**
|
||||
* How to join the MatrixRTC session. Only {@link createCallViewModel$}
|
||||
* reads it; defaults to {@link joinParamsFromConfig} over the deployment's
|
||||
* `matrix_rtc_session` configuration and {@link callIntent}.
|
||||
*/
|
||||
joinParams?: FfiJoinParams;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -491,7 +520,7 @@ export interface CallViewModel {
|
||||
// Throughout this class and related code we must distinguish between MatrixRTC
|
||||
// state and LiveKit state. We use the common terminology of room "members", RTC
|
||||
// "memberships", and LiveKit "participants".
|
||||
export function createCallViewModel$(
|
||||
export function createJsClientCallViewModel$(
|
||||
scope: ObservableScope,
|
||||
// A call is permanently tied to a single Matrix room
|
||||
matrixRTCSession: MatrixRTCSession,
|
||||
@@ -515,8 +544,6 @@ export function createCallViewModel$(
|
||||
const {
|
||||
hostBridge = nullHostBridge,
|
||||
controlledAudioDevices = false,
|
||||
header = HeaderStyle.Standard,
|
||||
showControls = true,
|
||||
hideScreensharing = false,
|
||||
sendNotificationType,
|
||||
callIntent,
|
||||
@@ -741,9 +768,86 @@ export function createCallViewModel$(
|
||||
options,
|
||||
localUser: { userId, deviceId },
|
||||
});
|
||||
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
|
||||
scope,
|
||||
matrixRTCSession,
|
||||
);
|
||||
|
||||
return assembleCallViewModel(
|
||||
scope,
|
||||
{
|
||||
localMembership,
|
||||
matrixLivekitMembers$,
|
||||
remoteMatrixLivekitMembers$,
|
||||
localMatrixLivekitMember$,
|
||||
matrixMemberMetadataStore,
|
||||
matrixRoomMembers$,
|
||||
ringAttempts$,
|
||||
autoLeave$,
|
||||
connectionManagerData$: connectionManager.connectionManagerData$,
|
||||
keyRotationSuppressed$,
|
||||
},
|
||||
options,
|
||||
mediaDevices,
|
||||
handsRaisedSubject$,
|
||||
reactionsSubject$,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Matrix-side inputs the call view model is assembled from: what a
|
||||
* MatrixRTC implementation has to provide so that the rest — tiles, layout,
|
||||
* sounds, header and footer — is the same whichever one it is.
|
||||
*
|
||||
* {@link createJsClientCallViewModel$} builds it from matrix-js-sdk's
|
||||
* `MatrixRTCSession`; {@link createCallViewModel$} from a
|
||||
* {@link CallParticipation} over the drivers.
|
||||
*/
|
||||
export interface CallViewModelCore {
|
||||
localMembership: LocalMembership;
|
||||
matrixLivekitMembers$: Behavior<
|
||||
(LocalMatrixLivekitMember | RemoteMatrixLivekitMember)[]
|
||||
>;
|
||||
remoteMatrixLivekitMembers$: Behavior<Epoch<RemoteMatrixLivekitMember[]>>;
|
||||
localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null>;
|
||||
matrixMemberMetadataStore: MatrixMemberMetadata;
|
||||
/** The room's members, for the ringing name and the name-tag threshold. */
|
||||
matrixRoomMembers$: Behavior<RoomMemberMap>;
|
||||
ringAttempts$: Observable<RingAttempt>;
|
||||
autoLeave$: Observable<AutoLeaveReason>;
|
||||
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
|
||||
keyRotationSuppressed$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
/** The half of the view model that does not care where the call comes from. */
|
||||
function assembleCallViewModel(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
localMembership,
|
||||
matrixLivekitMembers$,
|
||||
remoteMatrixLivekitMembers$,
|
||||
localMatrixLivekitMember$,
|
||||
matrixMemberMetadataStore,
|
||||
matrixRoomMembers$,
|
||||
ringAttempts$,
|
||||
autoLeave$,
|
||||
connectionManagerData$,
|
||||
keyRotationSuppressed$,
|
||||
}: CallViewModelCore,
|
||||
options: CallViewModelOptions,
|
||||
mediaDevices: MediaDevices,
|
||||
handsRaisedSubject$: Observable<Record<string, RaisedHandInfo>>,
|
||||
reactionsSubject$: Observable<Record<string, ReactionInfo>>,
|
||||
): CallViewModel {
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
const {
|
||||
hostBridge = nullHostBridge,
|
||||
header = HeaderStyle.Standard,
|
||||
showControls = true,
|
||||
} = options;
|
||||
|
||||
const allConnections$ = scope.behavior(
|
||||
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
|
||||
connectionManagerData$.pipe(map((d) => d.value)),
|
||||
);
|
||||
const livekitRoomItems$ = scope.behavior(
|
||||
remoteMatrixLivekitMembers$.pipe(
|
||||
@@ -838,7 +942,9 @@ export function createCallViewModel$(
|
||||
createWrappedUserMedia(scope, {
|
||||
id: `${mediaId}:${dup}`,
|
||||
userId,
|
||||
rtcBackendIdentity: rtcId,
|
||||
// Shown for debugging only; a member whose identity is not yet
|
||||
// known has none to show.
|
||||
rtcBackendIdentity: rtcId ?? "",
|
||||
participant,
|
||||
encryptionSystem: options.encryptionSystem,
|
||||
livekitRoom$: scope.behavior(
|
||||
@@ -946,11 +1052,6 @@ export function createCallViewModel$(
|
||||
matrixLivekitMembers$.pipe(map((ms) => ms.length)),
|
||||
);
|
||||
|
||||
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
|
||||
scope,
|
||||
matrixRTCSession,
|
||||
);
|
||||
|
||||
const leaveSoundEffect$ = userMedia$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
@@ -1923,6 +2024,242 @@ export function createCallViewModel$(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The call view model over the host's drivers: a {@link CallParticipation}
|
||||
* (the crate: memberships, connections and their tokens, media keys, our own
|
||||
* membership) and an {@link ElementCallMatrixClientDriver} (the room's
|
||||
* members and metadata, the timeline for notifications).
|
||||
*
|
||||
* The counterpart of {@link createJsClientCallViewModel$}, which reads the
|
||||
* same things from matrix-js-sdk; both assemble the same view model.
|
||||
*/
|
||||
export function createCallViewModel$(
|
||||
scope: ObservableScope,
|
||||
participation: CallParticipation,
|
||||
clientDriver: ElementCallMatrixClientDriver,
|
||||
mediaDevices: MediaDevices,
|
||||
muteStates: MuteStates,
|
||||
options: CallViewModelOptions,
|
||||
handsRaisedSubject$: Observable<Record<string, RaisedHandInfo>>,
|
||||
reactionsSubject$: Observable<Record<string, ReactionInfo>>,
|
||||
trackProcessorState$: Behavior<ProcessorState>,
|
||||
): CallViewModel {
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
const { userId, deviceId, roomId } = clientDriver;
|
||||
const {
|
||||
hostBridge = nullHostBridge,
|
||||
controlledAudioDevices = false,
|
||||
hideScreensharing = false,
|
||||
callIntent,
|
||||
} = options;
|
||||
|
||||
const livekitKeyProvider = getParticipationKeyProvider(
|
||||
options.encryptionSystem,
|
||||
scope,
|
||||
participation,
|
||||
logger,
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// connections and remote members
|
||||
|
||||
const connectionFactory =
|
||||
options.connectionFactory ??
|
||||
new ECConnectionFactory(
|
||||
// The crate mints every token; no connection fetches its own.
|
||||
null,
|
||||
roomId,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
livekitKeyProvider,
|
||||
controlledAudioDevices,
|
||||
options.livekitRoomFactory,
|
||||
);
|
||||
|
||||
const connectionManager = createParticipationConnectionManager$({
|
||||
scope,
|
||||
participation,
|
||||
connectionFactory,
|
||||
ownIdentity: { userId, deviceId },
|
||||
logger,
|
||||
});
|
||||
|
||||
const remoteMatrixLivekitMembers$ = createParticipationRemoteMembers$({
|
||||
scope,
|
||||
participation,
|
||||
connectionManager,
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// localMembership
|
||||
|
||||
const roomInfo$ = observeDriver(
|
||||
scope,
|
||||
() => clientDriver.getRoomInfo(),
|
||||
(listener) => clientDriver.subscribeRoomInfo(listener),
|
||||
);
|
||||
// Whether the homeserver takes sticky events decides how a failed first
|
||||
// send reads; assume it does until the driver says otherwise.
|
||||
let stickyEventsSupported = true;
|
||||
clientDriver.getCapabilities().then(
|
||||
(capabilities) => {
|
||||
stickyEventsSupported = capabilities.stickyEvents;
|
||||
},
|
||||
(e) => logger.warn("Could not read the driver's capabilities", e),
|
||||
);
|
||||
|
||||
const localMembership = createParticipationLocalMembership$({
|
||||
scope,
|
||||
participation,
|
||||
connectionManager,
|
||||
createPublisherFactory: (connection: Connection) =>
|
||||
new Publisher(
|
||||
connection,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
trackProcessorState$,
|
||||
logger.getChild(
|
||||
"[Publisher " + connection.transport.livekit_service_url + "]",
|
||||
),
|
||||
controlledAudioDevices,
|
||||
),
|
||||
muteStates,
|
||||
hideScreensharing,
|
||||
hostBridge,
|
||||
joinParams:
|
||||
options.joinParams ??
|
||||
joinParamsFromConfig({
|
||||
session: Config.get().matrix_rtc_session,
|
||||
callIntent,
|
||||
}),
|
||||
slotPolicy$: scope.behavior(
|
||||
roomInfo$.pipe(
|
||||
map((info) => ({
|
||||
encrypted: info.encrypted,
|
||||
canOpen: info.canOpenSlot,
|
||||
})),
|
||||
),
|
||||
),
|
||||
customLivekitUrl$: customLivekitUrl.value$,
|
||||
disconnectContext: () => ({
|
||||
domain: userId.slice(userId.indexOf(":") + 1),
|
||||
stickyEventsSupported,
|
||||
}),
|
||||
roomId,
|
||||
logger: logger.getChild(`[${Date.now()}]`),
|
||||
});
|
||||
|
||||
const localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null> =
|
||||
scope.behavior(
|
||||
participation.ownMembership$.pipe(
|
||||
map((membership) =>
|
||||
membership === null ? null : callMemberOf(membership),
|
||||
),
|
||||
filterBehavior((member) => member !== null),
|
||||
map((membership$) => {
|
||||
if (membership$ === null) return null;
|
||||
return {
|
||||
membership$,
|
||||
participant: {
|
||||
type: "local" as const,
|
||||
value$: localMembership.participant$,
|
||||
},
|
||||
connection$: localMembership.connection$,
|
||||
userId,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const matrixLivekitMembers$ = scope.behavior(
|
||||
combineLatest(
|
||||
[localMatrixLivekitMember$, remoteMatrixLivekitMembers$],
|
||||
(local, remote) => [...(local === null ? [] : [local]), ...remote.value],
|
||||
),
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// matrixMemberMetadataStore
|
||||
|
||||
const matrixRoomMembers$ = createParticipationRoomMembers$(
|
||||
scope,
|
||||
clientDriver,
|
||||
);
|
||||
const callMemberUserIds$ = scope.behavior(
|
||||
participation.memberships$.pipe(
|
||||
mapEpoch((memberships) =>
|
||||
memberships.map((m) => ({ userId: m.member.userId })),
|
||||
),
|
||||
),
|
||||
);
|
||||
const matrixMemberMetadataStore = createMatrixMemberMetadata$(
|
||||
scope,
|
||||
scope.behavior(callMemberUserIds$.pipe(map((ms) => ms.value))),
|
||||
matrixRoomMembers$,
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// callLifecycle
|
||||
|
||||
const { ringAttempts$, autoLeave$ } = createCallNotificationLifecycle$({
|
||||
scope,
|
||||
memberships$: callMemberUserIds$,
|
||||
matrixRoomMembers$,
|
||||
sentCallNotification$: createParticipationSentCallNotification$({
|
||||
scope,
|
||||
participation,
|
||||
timeline: clientDriver,
|
||||
options,
|
||||
logger,
|
||||
}),
|
||||
receivedDecline$: createParticipationReceivedDecline$(clientDriver),
|
||||
options,
|
||||
localUser: { userId, deviceId },
|
||||
});
|
||||
|
||||
return assembleCallViewModel(
|
||||
scope,
|
||||
{
|
||||
localMembership,
|
||||
matrixLivekitMembers$,
|
||||
remoteMatrixLivekitMembers$,
|
||||
localMatrixLivekitMember$,
|
||||
matrixMemberMetadataStore,
|
||||
matrixRoomMembers$,
|
||||
ringAttempts$,
|
||||
autoLeave$,
|
||||
connectionManagerData$: connectionManager.connectionManagerData$,
|
||||
// The crate has no participant limit for key rotation.
|
||||
keyRotationSuppressed$: constant(false),
|
||||
},
|
||||
options,
|
||||
mediaDevices,
|
||||
handsRaisedSubject$,
|
||||
reactionsSubject$,
|
||||
);
|
||||
}
|
||||
|
||||
function getParticipationKeyProvider(
|
||||
e2eeSystem: EncryptionSystem,
|
||||
scope: ObservableScope,
|
||||
participation: CallParticipation,
|
||||
logger: Logger,
|
||||
): BaseKeyProvider | undefined {
|
||||
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
|
||||
|
||||
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
const keyProvider = new ParticipationKeyProvider();
|
||||
keyProvider.attach(scope, participation);
|
||||
return keyProvider;
|
||||
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
|
||||
const keyProvider = new ExternalE2EEKeyProvider();
|
||||
keyProvider
|
||||
.setKey(e2eeSystem.secret)
|
||||
.catch((e) => logger.error("Failed to set shared key for E2EE", e));
|
||||
return keyProvider;
|
||||
}
|
||||
}
|
||||
|
||||
function getE2eeKeyProvider(
|
||||
e2eeSystem: EncryptionSystem,
|
||||
rtcSession: MatrixRTCSession,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { E2eeType } from "../../e2ee/e2eeType";
|
||||
import { type RaisedHandInfo, type ReactionInfo } from "../../reactions";
|
||||
import {
|
||||
type CallViewModel,
|
||||
createCallViewModel$,
|
||||
createJsClientCallViewModel$,
|
||||
type CallViewModelOptions,
|
||||
} from "./CallViewModel";
|
||||
import {
|
||||
@@ -197,7 +197,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
|
||||
setE2EEEnabled: async () => Promise.resolve(),
|
||||
});
|
||||
|
||||
const vm = createCallViewModel$(
|
||||
const vm = createJsClientCallViewModel$(
|
||||
testScope(),
|
||||
rtcSession.asMockedSession(),
|
||||
room,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
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 } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { testScope } from "../../utils/test";
|
||||
import {
|
||||
FakeParticipation,
|
||||
fakeMembership,
|
||||
} from "../../utils/test-participation";
|
||||
import { MockElementCallMatrixClientDriver } from "../../driver/MockElementCallMatrixClientDriver";
|
||||
import { waitFor } from "../../driver/MockRtcMatrixDriver";
|
||||
import { type DeclineEvent } from "./CallNotificationLifecycle";
|
||||
import {
|
||||
createParticipationReceivedDecline$,
|
||||
createParticipationSentCallNotification$,
|
||||
RTC_DECLINE_EVENT_TYPE,
|
||||
RTC_NOTIFICATION_EVENT_TYPE,
|
||||
} from "./ParticipationCallNotification";
|
||||
|
||||
const own = fakeMembership({
|
||||
member: { memberId: "m-me", userId: "@me:x", eventId: "$my-join" },
|
||||
});
|
||||
const peer = fakeMembership({ member: { memberId: "m-peer" } });
|
||||
|
||||
describe("createParticipationSentCallNotification$", () => {
|
||||
it("rings once our membership echoes back, if we were first, and again after a rejoin", async () => {
|
||||
const participation = new FakeParticipation();
|
||||
const timeline = new MockElementCallMatrixClientDriver();
|
||||
const sent$ = createParticipationSentCallNotification$({
|
||||
scope: testScope(),
|
||||
participation,
|
||||
timeline,
|
||||
options: { sendNotificationType: "ring", callIntent: "video" },
|
||||
logger,
|
||||
});
|
||||
expect(sent$.value).toBeNull();
|
||||
|
||||
// Our echo arrives; the roster has only us.
|
||||
participation.setMemberships([own]);
|
||||
participation.ownMembership$.next(own);
|
||||
await waitFor("notification sent", () => sent$.value !== null);
|
||||
const [call] = timeline.calls("sendRoomEvent");
|
||||
expect(call.eventType).toBe(RTC_NOTIFICATION_EVENT_TYPE);
|
||||
expect(call.content).toMatchObject({
|
||||
notification_type: "ring",
|
||||
"m.call.intent": "video",
|
||||
"m.relates_to": { event_id: "$my-join", rel_type: "m.reference" },
|
||||
lifetime: 90_000,
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
});
|
||||
expect(sent$.value).toMatchObject({
|
||||
event_id: call.eventId,
|
||||
notification_type: "ring",
|
||||
});
|
||||
|
||||
// A refresh of our membership is not a join.
|
||||
participation.ownMembership$.next({ ...own });
|
||||
expect(timeline.calls("sendRoomEvent")).toHaveLength(1);
|
||||
|
||||
// We leave and come back alone: the room rings again.
|
||||
participation.ownMembership$.next(null);
|
||||
participation.setMemberships([]);
|
||||
expect(sent$.value).toBeNull();
|
||||
participation.setMemberships([own]);
|
||||
participation.ownMembership$.next(own);
|
||||
await waitFor(
|
||||
"second notification",
|
||||
() => timeline.calls("sendRoomEvent").length === 2,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not ring when somebody was in the session before us", async () => {
|
||||
const participation = new FakeParticipation();
|
||||
const timeline = new MockElementCallMatrixClientDriver();
|
||||
const sent$ = createParticipationSentCallNotification$({
|
||||
scope: testScope(),
|
||||
participation,
|
||||
timeline,
|
||||
options: { sendNotificationType: "ring" },
|
||||
logger,
|
||||
});
|
||||
participation.setMemberships([peer, own]);
|
||||
participation.ownMembership$.next(own);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(timeline.calls("sendRoomEvent")).toEqual([]);
|
||||
expect(sent$.value).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing without a notification type", async () => {
|
||||
const participation = new FakeParticipation();
|
||||
const timeline = new MockElementCallMatrixClientDriver();
|
||||
createParticipationSentCallNotification$({
|
||||
scope: testScope(),
|
||||
participation,
|
||||
timeline,
|
||||
options: {},
|
||||
logger,
|
||||
});
|
||||
participation.setMemberships([own]);
|
||||
participation.ownMembership$.next(own);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(timeline.calls("sendRoomEvent")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createParticipationReceivedDecline$", () => {
|
||||
it("reports declines with who sent them and what they relate to", () => {
|
||||
const timeline = new MockElementCallMatrixClientDriver();
|
||||
const declines: DeclineEvent[] = [];
|
||||
createParticipationReceivedDecline$(timeline).subscribe((d) =>
|
||||
declines.push(d),
|
||||
);
|
||||
timeline.emitTimelineEvent({
|
||||
eventId: "$other",
|
||||
type: "m.room.message",
|
||||
sender: "@peer:x",
|
||||
content: {},
|
||||
originServerTs: 1,
|
||||
});
|
||||
timeline.emitTimelineEvent({
|
||||
eventId: "$decline",
|
||||
type: RTC_DECLINE_EVENT_TYPE,
|
||||
sender: "@peer:x",
|
||||
content: {
|
||||
"m.relates_to": { rel_type: "m.reference", event_id: "$notif" },
|
||||
},
|
||||
originServerTs: 2,
|
||||
});
|
||||
expect(declines).toEqual([
|
||||
{
|
||||
sender: "@peer:x",
|
||||
relatesTo: { relType: "m.reference", eventId: "$notif" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
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 RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
pairwise,
|
||||
startWith,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type Epoch, type ObservableScope } from "../ObservableScope";
|
||||
import { type FfiMembership } from "../../matrix-rtc-sdk";
|
||||
import { type TimelineDriver } from "../../driver/ElementCallMatrixClientDriver";
|
||||
import {
|
||||
type CallNotificationWrapper,
|
||||
type DeclineEvent,
|
||||
} from "./CallNotificationLifecycle";
|
||||
|
||||
/** MSC4075, the unstable spelling every client sends and listens for. */
|
||||
export const RTC_NOTIFICATION_EVENT_TYPE =
|
||||
"org.matrix.msc4075.rtc.notification";
|
||||
/** MSC4310. */
|
||||
export const RTC_DECLINE_EVENT_TYPE = "org.matrix.msc4310.rtc.decline";
|
||||
/** How long a ring is offered for, as matrix-js-sdk has it. */
|
||||
export const NOTIFICATION_LIFETIME_MS = 90_000;
|
||||
|
||||
/** What sending the notification needs from a {@link CallParticipation}. */
|
||||
export interface ParticipationNotificationSource {
|
||||
ownMembership$: Behavior<FfiMembership | null>;
|
||||
memberships$: Behavior<Epoch<FfiMembership[]>>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
participation: ParticipationNotificationSource;
|
||||
timeline: TimelineDriver;
|
||||
options: {
|
||||
/** Whether and what kind of notification to send when joining the call. */
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/** The kind of call being placed. */
|
||||
callIntent?: RTCCallIntent;
|
||||
};
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the call notification (`m.rtc.notification`) when we start a call:
|
||||
* once our own membership has echoed back from the homeserver, and only if
|
||||
* nobody else was in the session before us — whoever is first rings the
|
||||
* room. Emits what was sent, so ringing can wait for the pickup, decline or
|
||||
* timeout that relates to it. Resets when we leave, so a later join can
|
||||
* ring again.
|
||||
*/
|
||||
export function createParticipationSentCallNotification$({
|
||||
scope,
|
||||
participation,
|
||||
timeline,
|
||||
options: { sendNotificationType, callIntent },
|
||||
logger: parentLogger,
|
||||
}: Props): Behavior<CallNotificationWrapper | null> {
|
||||
const logger = parentLogger.getChild("[CallNotification]");
|
||||
const sent$ = new BehaviorSubject<CallNotificationWrapper | null>(null);
|
||||
if (sendNotificationType === undefined) return scope.behavior(sent$);
|
||||
|
||||
participation.ownMembership$
|
||||
.pipe(
|
||||
startWith(null),
|
||||
pairwise(),
|
||||
withLatestFrom(participation.memberships$),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe(([[previous, own], memberships]) => {
|
||||
if (own === null) {
|
||||
// Left (or not in yet): the next join decides afresh.
|
||||
if (previous !== null) sent$.next(null);
|
||||
return;
|
||||
}
|
||||
if (previous !== null) return; // Already in; a refresh, not a join.
|
||||
const others = memberships.value.filter(
|
||||
(m) => m.member.memberId !== own.member.memberId,
|
||||
);
|
||||
if (others.length > 0) {
|
||||
logger.debug(
|
||||
`Not sending a call notification: ${others.length} member(s) were in the session before us`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const eventId = own.member.eventId;
|
||||
if (eventId === undefined) {
|
||||
logger.warn(
|
||||
"Own membership has no event id; cannot send the call notification",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const content: Record<string, unknown> = {
|
||||
"m.mentions": { user_ids: [], room: true },
|
||||
notification_type: sendNotificationType,
|
||||
"m.relates_to": { event_id: eventId, rel_type: "m.reference" },
|
||||
sender_ts: Date.now(),
|
||||
lifetime: NOTIFICATION_LIFETIME_MS,
|
||||
};
|
||||
if (callIntent !== undefined) content["m.call.intent"] = callIntent;
|
||||
timeline.sendRoomEvent(RTC_NOTIFICATION_EVENT_TYPE, content).then(
|
||||
({ eventId: notificationEventId }) => {
|
||||
logger.info(`Sent call notification ${notificationEventId}`);
|
||||
sent$.next({
|
||||
event_id: notificationEventId,
|
||||
...(content as Omit<CallNotificationWrapper, "event_id">),
|
||||
});
|
||||
},
|
||||
(e) => logger.error("Failed to send the call notification", e),
|
||||
);
|
||||
});
|
||||
|
||||
return scope.behavior(sent$);
|
||||
}
|
||||
|
||||
/** Declines (`m.rtc.decline`) arriving in the room, for the ringing outcome. */
|
||||
export function createParticipationReceivedDecline$(
|
||||
timeline: TimelineDriver,
|
||||
): Observable<DeclineEvent> {
|
||||
return new Observable<DeclineEvent>((subscriber) =>
|
||||
timeline.subscribeTimeline((event) => {
|
||||
if (event.type !== RTC_DECLINE_EVENT_TYPE) return;
|
||||
const relation = event.content["m.relates_to"] as
|
||||
| { rel_type?: string; event_id?: string }
|
||||
| undefined;
|
||||
subscriber.next({
|
||||
sender: event.sender,
|
||||
relatesTo: relation
|
||||
? { relType: relation.rel_type, eventId: relation.event_id }
|
||||
: undefined,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type LocalParticipant,
|
||||
MediaDeviceFailure,
|
||||
type Participant,
|
||||
ParticipantEvent,
|
||||
RoomEvent,
|
||||
type ScreenShareCaptureOptions,
|
||||
type TrackPublishOptions,
|
||||
} from "livekit-client";
|
||||
import { observeParticipantEvents } from "@livekit/components-core";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
from,
|
||||
fromEvent,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import { type Behavior } from "../../Behavior.ts";
|
||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||
import { type Publisher } from "./Publisher.ts";
|
||||
import {
|
||||
type ElementCallError,
|
||||
FailToStartLivekitConnection,
|
||||
UnknownCallError,
|
||||
} from "../../../utils/errors.ts";
|
||||
import { type HostBridge } from "../../../HostBridge.ts";
|
||||
import {
|
||||
advancedScreenShare,
|
||||
screenShareResolution,
|
||||
screenShareFramerate,
|
||||
screenShareBitrate,
|
||||
screenShareCodec,
|
||||
parseResolution,
|
||||
} from "../../../settings/settings.ts";
|
||||
import { Config } from "../../../config/Config.ts";
|
||||
import {
|
||||
ConnectionState,
|
||||
type Connection,
|
||||
type FailedToStartError,
|
||||
} from "../remoteMembers/Connection.ts";
|
||||
|
||||
export enum PublishState {
|
||||
WaitingForUser = "publish_waiting_for_user",
|
||||
// XXX: This state is removed for now since we do not have full control over
|
||||
// track publication anymore with the publisher abstraction, might come back in the future?
|
||||
// /** Implies lk connection is connected */
|
||||
// Starting = "publish_start_publishing",
|
||||
/** Implies lk connection is connected */
|
||||
Publishing = "publish_publishing",
|
||||
}
|
||||
|
||||
// TODO not sure how to map that correctly with the
|
||||
// new publisher that does not manage tracks itself anymore
|
||||
export enum TrackState {
|
||||
/** The track is waiting for user input to create tracks (waiting to call `startTracks()`) */
|
||||
WaitingForUser = "tracks_waiting_for_user",
|
||||
// XXX: This state is removed for now since we do not have full control over
|
||||
// track creation anymore with the publisher abstraction, might come back in the future?
|
||||
// /** Implies lk connection is connected */
|
||||
// Creating = "tracks_creating",
|
||||
/** Implies lk connection is connected */
|
||||
Ready = "tracks_ready",
|
||||
}
|
||||
|
||||
export type LocalMemberMediaState =
|
||||
| {
|
||||
tracks: TrackState;
|
||||
connection: ConnectionState | FailedToStartError;
|
||||
}
|
||||
| PublishState
|
||||
| ElementCallError;
|
||||
|
||||
export interface LocalMediaProps {
|
||||
scope: ObservableScope;
|
||||
/** The connection we publish our media on, once there is one. */
|
||||
localConnection$: Behavior<Connection | null>;
|
||||
/**
|
||||
* Whether the transport we publish on is known. Until it is, there is no
|
||||
* media state to speak of (`mediaState$` is null).
|
||||
*/
|
||||
transportReady$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether the Matrix side of the call is connected. Upstream media is paused
|
||||
* while it is not, so that "reconnecting" never means "still transmitting".
|
||||
*/
|
||||
matrixConnected$: Observable<boolean>;
|
||||
createPublisherFactory: (connection: Connection) => Publisher;
|
||||
/** Whether to hide the screen-sharing button. */
|
||||
hideScreensharing: boolean;
|
||||
/** The application hosting Element Call, to be kept informed of join/leave. */
|
||||
hostBridge: HostBridge;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface LocalMedia {
|
||||
/**
|
||||
* This request to start audio and video tracks.
|
||||
* Can be called early to pre-emptively get media permissions and start devices.
|
||||
*/
|
||||
startTracks: () => void;
|
||||
/**
|
||||
* This sets a inner state (shouldPublish) to true and instructs the Matrix
|
||||
* side and livekit to keep the user connected.
|
||||
*/
|
||||
requestJoinAndPublish: () => void;
|
||||
requestDisconnect: () => void;
|
||||
/** What the user last asked for: to be in the call, or out of it. */
|
||||
joinAndPublishRequested$: Behavior<boolean>;
|
||||
participant$: Behavior<LocalParticipant | null>;
|
||||
/** The state of the connection we publish on; null without one. */
|
||||
localConnectionState$: Observable<ConnectionState | Error | null>;
|
||||
/** Null until the transport is known. */
|
||||
mediaState$: Behavior<LocalMemberMediaState | null>;
|
||||
/** A non-fatal failure to publish; we can still consume media. */
|
||||
publishError$: Behavior<ElementCallError | null>;
|
||||
sharingScreen$: Behavior<boolean>;
|
||||
/**
|
||||
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
||||
*/
|
||||
toggleScreenSharing: (() => void) | null;
|
||||
/**
|
||||
* The last error from toggling screen sharing, until dismissed.
|
||||
*/
|
||||
screenShareError$: Behavior<Error | null>;
|
||||
dismissScreenShareError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The LiveKit half of our own membership, shared by every Matrix side there
|
||||
* is: creating a publisher on the local connection, starting tracks and
|
||||
* publishing them when asked, screen sharing, pausing upstream media while
|
||||
* Matrix is away, and telling the host when the user joins or hangs up.
|
||||
*/
|
||||
export function createLocalMedia$({
|
||||
scope,
|
||||
localConnection$,
|
||||
transportReady$,
|
||||
matrixConnected$,
|
||||
createPublisherFactory,
|
||||
hideScreensharing,
|
||||
hostBridge,
|
||||
logger,
|
||||
}: LocalMediaProps): LocalMedia {
|
||||
// Tracks error that happen when creating the local tracks.
|
||||
const mediaErrors$ = localConnection$.pipe(
|
||||
switchMap((connection) => {
|
||||
if (!connection) {
|
||||
return of(null);
|
||||
} else {
|
||||
return fromEvent(
|
||||
connection.livekitRoom,
|
||||
RoomEvent.MediaDevicesError,
|
||||
(error: Error) => {
|
||||
return MediaDeviceFailure.getFailure(error) ?? null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
mediaErrors$.pipe(scope.bind()).subscribe((error) => {
|
||||
if (error) {
|
||||
// This is a MediaDevice error, can be PermissionDenied, NotFound, DeviceInUse, Other.
|
||||
// Will also occurs if you cancel screen sharing browser prompt.
|
||||
// This is not necessarily fatal, since the user might be able to join without media.
|
||||
// XXX We might want to give some user feedback here to let them know their media is not working.
|
||||
logger.error(`Failed to create local tracks:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// This should be used in a combineLatest with publisher$ to connect.
|
||||
// to make it possible to call startTracks before the preferredTransport$ has resolved.
|
||||
const trackStartRequested = Promise.withResolvers<void>();
|
||||
|
||||
// This should be used in a combineLatest with publisher$ to connect.
|
||||
// to make it possible to call startTracks before the preferredTransport$ has resolved.
|
||||
const joinAndPublishRequested$ = new BehaviorSubject(false);
|
||||
|
||||
/**
|
||||
* The publisher is stored in here an abstracts creating and publishing tracks.
|
||||
*/
|
||||
const publisher$ = new BehaviorSubject<Publisher | null>(null);
|
||||
|
||||
const startTracks = (): void => {
|
||||
trackStartRequested.resolve();
|
||||
// This used to return the tracks, but now they are only accessible via the publisher.
|
||||
};
|
||||
|
||||
const requestJoinAndPublish = (): void => {
|
||||
trackStartRequested.resolve();
|
||||
joinAndPublishRequested$.next(true);
|
||||
};
|
||||
|
||||
const requestDisconnect = (): void => {
|
||||
joinAndPublishRequested$.next(false);
|
||||
};
|
||||
|
||||
// Take care of the publisher$
|
||||
// create a new one as soon as a local Connection is available
|
||||
//
|
||||
// Recreate a new one once the local connection changes
|
||||
// - stop publishing
|
||||
// - destruct all current streams
|
||||
// - overwrite current publisher
|
||||
scope.reconcile(localConnection$, async (connection) => {
|
||||
logger.info(
|
||||
"reconcile based on new localConnection:",
|
||||
connection?.transport.livekit_service_url,
|
||||
);
|
||||
if (connection !== null) {
|
||||
const publisher = createPublisherFactory(connection);
|
||||
publisher$.next(publisher);
|
||||
|
||||
// Clean-up callback
|
||||
return Promise.resolve(async (): Promise<void> => {
|
||||
await publisher.destroy();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Use reconcile here to not run concurrent createAndSetupTracks calls
|
||||
// `tracks$` will update once they are ready.
|
||||
scope.reconcile(
|
||||
scope.behavior(
|
||||
combineLatest([
|
||||
publisher$ /*, tracks$*/,
|
||||
from(trackStartRequested.promise),
|
||||
]),
|
||||
null,
|
||||
),
|
||||
async (valueIfReady) => {
|
||||
if (!valueIfReady) return;
|
||||
const [publisher] = valueIfReady;
|
||||
if (publisher) {
|
||||
await publisher.createAndSetupTracks().catch((e) => logger.error(e));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// These are non fatal since we can join a room and concume media even though publishing failed.
|
||||
const publishError$ = new BehaviorSubject<ElementCallError | null>(null);
|
||||
const setPublishError = (e: ElementCallError): void => {
|
||||
if (publishError$.value !== null) {
|
||||
logger.error("Multiple Media Errors:", e);
|
||||
} else {
|
||||
publishError$.next(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Based on `connectRequested$` we start publishing tracks. (once they are there!)
|
||||
scope.reconcile(
|
||||
scope.behavior(combineLatest([publisher$, joinAndPublishRequested$])),
|
||||
async ([publisher, shouldJoinAndPublish]) => {
|
||||
// Get the current publishing state to avoid redundant calls.
|
||||
const isPublishing = publisher?.shouldPublish === true;
|
||||
if (shouldJoinAndPublish && !isPublishing) {
|
||||
try {
|
||||
await publisher?.startPublishing();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setPublishError(new FailToStartLivekitConnection(message));
|
||||
}
|
||||
} else if (isPublishing) {
|
||||
try {
|
||||
await publisher?.stopPublishing();
|
||||
} catch (error) {
|
||||
setPublishError(new UnknownCallError(error as Error));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const localConnectionState$ = localConnection$.pipe(
|
||||
switchMap((connection) => (connection ? connection.state$ : of(null))),
|
||||
);
|
||||
|
||||
const mediaState$: Behavior<LocalMemberMediaState | null> = scope.behavior(
|
||||
combineLatest([
|
||||
localConnectionState$,
|
||||
transportReady$,
|
||||
joinAndPublishRequested$,
|
||||
from(trackStartRequested.promise).pipe(
|
||||
map(() => true),
|
||||
startWith(false),
|
||||
),
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
localConnectionState,
|
||||
transportReady,
|
||||
shouldPublish,
|
||||
shouldStartTracks,
|
||||
]) => {
|
||||
if (!transportReady) return null;
|
||||
const trackState: TrackState = shouldStartTracks
|
||||
? TrackState.Ready
|
||||
: TrackState.WaitingForUser;
|
||||
|
||||
if (
|
||||
localConnectionState !== ConnectionState.LivekitConnected ||
|
||||
trackState !== TrackState.Ready
|
||||
)
|
||||
return {
|
||||
connection: localConnectionState,
|
||||
tracks: trackState,
|
||||
};
|
||||
if (!shouldPublish) return PublishState.WaitingForUser;
|
||||
// if (!publishing) return PublishState.Starting;
|
||||
return PublishState.Publishing;
|
||||
},
|
||||
),
|
||||
distinctUntilChanged(deepCompare),
|
||||
),
|
||||
);
|
||||
|
||||
// inform the host about the connect and disconnect intent from the user.
|
||||
scope
|
||||
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
|
||||
undefined,
|
||||
joinAndPublishRequested$.value,
|
||||
])
|
||||
.subscribe(([prev, current]) => {
|
||||
// JOIN prev=false (was left) => current-true (now joiend)
|
||||
if (!prev && current) {
|
||||
hostBridge.notifyJoined().catch((e) => {
|
||||
logger.error("Failed to notify the host that we joined", e);
|
||||
});
|
||||
}
|
||||
// LEAVE prev=false (was joined) => current-true (now left)
|
||||
if (prev && !current) {
|
||||
hostBridge.notifyHungUp().catch((e) => {
|
||||
logger.error("Failed to notify the host that we hung up", e);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const participant$ = scope.behavior(
|
||||
localConnection$.pipe(
|
||||
map((c) => c?.livekitRoom?.localParticipant ?? null),
|
||||
tap((p) => {
|
||||
logger.debug("participant$ updated:", p?.identity);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Pause upstream of all local media tracks when we're disconnected from
|
||||
// MatrixRTC, because it can be an unpleasant surprise for the app to say
|
||||
// 'reconnecting' and yet still be transmitting your media to others.
|
||||
// We use matrixConnected$ rather than reconnecting$ because we want to
|
||||
// pause tracks during the initial joining sequence too until we're sure
|
||||
// that our own media is displayed on screen.
|
||||
// TODO refactor this based no livekitState$
|
||||
combineLatest([participant$, matrixConnected$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([participant, connected]) => {
|
||||
if (!participant) return;
|
||||
const publications = participant.trackPublications.values();
|
||||
if (connected) {
|
||||
for (const p of publications) {
|
||||
if (p.track?.isUpstreamPaused === true) {
|
||||
const kind = p.track.kind;
|
||||
logger.info(
|
||||
`Resuming ${kind} track (MatrixRTC connection present)`,
|
||||
);
|
||||
p.track
|
||||
.resumeUpstream()
|
||||
.catch((e) =>
|
||||
logger.error(
|
||||
`Failed to resume ${kind} track after MatrixRTC reconnection`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const p of publications) {
|
||||
if (p.track?.isUpstreamPaused === false) {
|
||||
const kind = p.track.kind;
|
||||
logger.info(
|
||||
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
|
||||
);
|
||||
p.track
|
||||
.pauseUpstream()
|
||||
.catch((e) =>
|
||||
logger.error(
|
||||
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the user is currently sharing their screen.
|
||||
*/
|
||||
const sharingScreen$ = scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) => (p !== null ? observeSharingScreen$(p) : of(false))),
|
||||
),
|
||||
);
|
||||
|
||||
const screenShareError$ = new BehaviorSubject<Error | null>(null);
|
||||
let toggleScreenSharing: (() => void) | null = null;
|
||||
if (
|
||||
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
|
||||
!hideScreensharing
|
||||
) {
|
||||
toggleScreenSharing = (): void => {
|
||||
const screenshareSettings: ScreenShareCaptureOptions = {
|
||||
// Screen share audio shouldn't have any filtering.
|
||||
// "echoCancellation" is purposely excluded, as setting it to
|
||||
// false causes the screen share audio track to include
|
||||
// an echo of the incoming participant's voice
|
||||
audio: {
|
||||
autoGainControl: false,
|
||||
noiseSuppression: false,
|
||||
voiceIsolation: false,
|
||||
},
|
||||
selfBrowserSurface: "include",
|
||||
surfaceSwitching: "include",
|
||||
systemAudio: "include",
|
||||
};
|
||||
|
||||
let publishOptions: TrackPublishOptions | undefined;
|
||||
|
||||
if (advancedScreenShare.getValue()) {
|
||||
// User has advanced screen share settings enabled
|
||||
const { width, height } = parseResolution(
|
||||
screenShareResolution.getValue(),
|
||||
);
|
||||
const fps = screenShareFramerate.getValue();
|
||||
const bps = screenShareBitrate.getValue();
|
||||
const codec = screenShareCodec.getValue();
|
||||
|
||||
screenshareSettings.resolution = {
|
||||
width,
|
||||
height,
|
||||
frameRate: fps,
|
||||
};
|
||||
|
||||
publishOptions = {
|
||||
screenShareEncoding: {
|
||||
maxBitrate: bps,
|
||||
maxFramerate: fps,
|
||||
},
|
||||
videoCodec: codec,
|
||||
};
|
||||
} else {
|
||||
// Fall back to config.json settings if available
|
||||
const screenConf = Config.get().media_quality?.screen_share;
|
||||
if (screenConf?.max_resolution) {
|
||||
screenshareSettings.resolution = {
|
||||
width: Math.round((screenConf.max_resolution * 16) / 9),
|
||||
height: screenConf.max_resolution,
|
||||
frameRate: screenConf.max_framerate ?? 30,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const targetScreenshareState = !sharingScreen$.value;
|
||||
logger.info(
|
||||
`toggleScreenSharing called. Switching ${
|
||||
targetScreenshareState ? "On" : "Off"
|
||||
}`,
|
||||
);
|
||||
// If a connection is ready, toggle screen sharing.
|
||||
// We deliberately do nothing in the case of a null connection because
|
||||
// it looks nice for the call control buttons to all become available
|
||||
// at once upon joining the call, rather than introducing a disabled
|
||||
// state. The user can just click again.
|
||||
// We also allow screen sharing to be toggled even if the connection
|
||||
// is still initializing or publishing tracks, because there's no
|
||||
// technical reason to disallow this. LiveKit will publish if it can.
|
||||
const participant = participant$.value;
|
||||
if (!participant) return;
|
||||
watchScreenShareToggle(
|
||||
participant.setScreenShareEnabled(
|
||||
targetScreenshareState,
|
||||
screenshareSettings,
|
||||
publishOptions,
|
||||
),
|
||||
targetScreenshareState,
|
||||
logger,
|
||||
(e) => screenShareError$.next(e),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startTracks,
|
||||
requestJoinAndPublish,
|
||||
requestDisconnect,
|
||||
joinAndPublishRequested$,
|
||||
participant$,
|
||||
localConnectionState$,
|
||||
mediaState$,
|
||||
publishError$,
|
||||
sharingScreen$,
|
||||
toggleScreenSharing,
|
||||
screenShareError$,
|
||||
dismissScreenShareError: () => screenShareError$.next(null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the outcome of a screen share toggle and reports failures.
|
||||
*
|
||||
* getDisplayMedia may legitimately take a long time (the user is choosing
|
||||
* what to share) or never settle at all, so nothing is inferred from silence:
|
||||
* the request and its completion are logged with the elapsed time so that a
|
||||
* hang is visible in the logs, and only an explicit rejection is reported.
|
||||
*
|
||||
* The user cancelling the picker rejects with a NotAllowedError; that is
|
||||
* logged but not reported.
|
||||
*/
|
||||
export function watchScreenShareToggle(
|
||||
toggle: Promise<unknown>,
|
||||
enable: boolean,
|
||||
logger: Logger,
|
||||
onError: (e: Error) => void,
|
||||
): void {
|
||||
const what = `Screen share ${enable ? "start" : "stop"}`;
|
||||
const started = Date.now();
|
||||
const elapsed = (): string => `${Date.now() - started} ms`;
|
||||
logger.info(`${what} requested`);
|
||||
toggle.then(
|
||||
() => logger.info(`${what} completed in ${elapsed()}`),
|
||||
(e: unknown) => {
|
||||
logger.error(`${what} failed after ${elapsed()}:`, e);
|
||||
if (e instanceof DOMException && e.name === "NotAllowedError") return;
|
||||
onError(e instanceof Error ? e : new Error(String(e)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function observeSharingScreen$(p: Participant): Observable<boolean> {
|
||||
return observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.TrackPublished,
|
||||
ParticipantEvent.TrackUnpublished,
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
ParticipantEvent.LocalTrackUnpublished,
|
||||
).pipe(map((p) => p.isScreenShareEnabled));
|
||||
}
|
||||
@@ -5,16 +5,7 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type Participant,
|
||||
ParticipantEvent,
|
||||
type LocalParticipant,
|
||||
type ScreenShareCaptureOptions,
|
||||
type TrackPublishOptions,
|
||||
RoomEvent,
|
||||
MediaDeviceFailure,
|
||||
} from "livekit-client";
|
||||
import { observeParticipantEvents } from "@livekit/components-core";
|
||||
import { type LocalParticipant } from "livekit-client";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
Status as RTCSessionStatus,
|
||||
@@ -30,7 +21,6 @@ import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
from,
|
||||
fromEvent,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
@@ -40,31 +30,22 @@ import {
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import { type Behavior } from "../../Behavior.ts";
|
||||
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
|
||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||
import { type Publisher } from "./Publisher.ts";
|
||||
import { createLocalMedia$, type LocalMemberMediaState } from "./LocalMedia.ts";
|
||||
import { type MuteStates } from "../../MuteStates.ts";
|
||||
import {
|
||||
ElementCallError,
|
||||
FailToStartLivekitConnection,
|
||||
MembershipManagerError,
|
||||
UnknownCallError,
|
||||
} from "../../../utils/errors.ts";
|
||||
import { type HostBridge } from "../../../HostBridge.ts";
|
||||
|
||||
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
||||
import {
|
||||
advancedScreenShare,
|
||||
screenShareResolution,
|
||||
screenShareFramerate,
|
||||
screenShareBitrate,
|
||||
screenShareCodec,
|
||||
parseResolution,
|
||||
} from "../../../settings/settings.ts";
|
||||
import {
|
||||
MatrixRTCMode,
|
||||
type ResolvedDelayedLeaveTimings,
|
||||
@@ -73,7 +54,6 @@ import { Config } from "../../../config/Config.ts";
|
||||
import {
|
||||
ConnectionState,
|
||||
type Connection,
|
||||
type FailedToStartError,
|
||||
} from "../remoteMembers/Connection.ts";
|
||||
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||
import { type LocalTransport } from "./LocalTransport.ts";
|
||||
@@ -86,42 +66,31 @@ export enum TransportState {
|
||||
Waiting = "transport_waiting",
|
||||
}
|
||||
|
||||
export enum PublishState {
|
||||
WaitingForUser = "publish_waiting_for_user",
|
||||
// XXX: This state is removed for now since we do not have full control over
|
||||
// track publication anymore with the publisher abstraction, might come back in the future?
|
||||
// /** Implies lk connection is connected */
|
||||
// Starting = "publish_start_publishing",
|
||||
/** Implies lk connection is connected */
|
||||
Publishing = "publish_publishing",
|
||||
export {
|
||||
PublishState,
|
||||
TrackState,
|
||||
type LocalMemberMediaState,
|
||||
watchScreenShareToggle,
|
||||
observeSharingScreen$,
|
||||
} from "./LocalMedia.ts";
|
||||
|
||||
/**
|
||||
* The crate's view of our membership, for `LocalMemberState.matrix` when the
|
||||
* call runs over a `CallParticipation` (matrix-js-sdk reports its own
|
||||
* `RTCSessionStatus` there).
|
||||
*/
|
||||
export enum MatrixConnectionStatus {
|
||||
Disconnected = "matrix_disconnected",
|
||||
Connecting = "matrix_connecting",
|
||||
Connected = "matrix_connected",
|
||||
}
|
||||
|
||||
// TODO not sure how to map that correctly with the
|
||||
// new publisher that does not manage tracks itself anymore
|
||||
export enum TrackState {
|
||||
/** The track is waiting for user input to create tracks (waiting to call `startTracks()`) */
|
||||
WaitingForUser = "tracks_waiting_for_user",
|
||||
// XXX: This state is removed for now since we do not have full control over
|
||||
// track creation anymore with the publisher abstraction, might come back in the future?
|
||||
// /** Implies lk connection is connected */
|
||||
// Creating = "tracks_creating",
|
||||
/** Implies lk connection is connected */
|
||||
Ready = "tracks_ready",
|
||||
}
|
||||
|
||||
export type LocalMemberMediaState =
|
||||
| {
|
||||
tracks: TrackState;
|
||||
connection: ConnectionState | FailedToStartError;
|
||||
}
|
||||
| PublishState
|
||||
| ElementCallError;
|
||||
export type LocalMemberState =
|
||||
| ElementCallError
|
||||
| TransportState.Waiting
|
||||
| {
|
||||
media: LocalMemberMediaState;
|
||||
matrix: ElementCallError | RTCSessionStatus;
|
||||
matrix: ElementCallError | RTCSessionStatus | MatrixConnectionStatus;
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -191,6 +160,9 @@ interface Props {
|
||||
* - connectionState: the current connection state. Including matrix server and livekit server connection.
|
||||
* - sharingScreen$: Whether we are sharing our screen. `undefined` if we cannot share the screen.
|
||||
*/
|
||||
/** Our own membership as the view model sees it, whichever Matrix side made it. */
|
||||
export type LocalMembership = ReturnType<typeof createLocalMembership$>;
|
||||
|
||||
export const createLocalMembership$ = ({
|
||||
scope,
|
||||
connectionManager,
|
||||
@@ -357,139 +329,36 @@ export const createLocalMembership$ = ({
|
||||
),
|
||||
);
|
||||
|
||||
// Tracks error that happen when creating the local tracks.
|
||||
const mediaErrors$ = localConnection$.pipe(
|
||||
switchMap((connection) => {
|
||||
if (!connection) {
|
||||
return of(null);
|
||||
} else {
|
||||
return fromEvent(
|
||||
connection.livekitRoom,
|
||||
RoomEvent.MediaDevicesError,
|
||||
(error: Error) => {
|
||||
return MediaDeviceFailure.getFailure(error) ?? null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
mediaErrors$.pipe(scope.bind()).subscribe((error) => {
|
||||
if (error) {
|
||||
// This is a MediaDevice error, can be PermissionDenied, NotFound, DeviceInUse, Other.
|
||||
// Will also occurs if you cancel screen sharing browser prompt.
|
||||
// This is not necessarily fatal, since the user might be able to join without media.
|
||||
// XXX We might want to give some user feedback here to let them know their media is not working.
|
||||
logger.error(`Failed to create local tracks:`, error);
|
||||
}
|
||||
});
|
||||
// MATRIX RELATED
|
||||
|
||||
// This should be used in a combineLatest with publisher$ to connect.
|
||||
// to make it possible to call startTracks before the preferredTransport$ has resolved.
|
||||
const trackStartRequested = Promise.withResolvers<void>();
|
||||
|
||||
// This should be used in a combineLatest with publisher$ to connect.
|
||||
// to make it possible to call startTracks before the preferredTransport$ has resolved.
|
||||
const joinAndPublishRequested$ = new BehaviorSubject(false);
|
||||
|
||||
/**
|
||||
* The publisher is stored in here an abstracts creating and publishing tracks.
|
||||
*/
|
||||
const publisher$ = new BehaviorSubject<Publisher | null>(null);
|
||||
|
||||
const startTracks = (): void => {
|
||||
trackStartRequested.resolve();
|
||||
// This used to return the tracks, but now they are only accessible via the publisher.
|
||||
};
|
||||
|
||||
const requestJoinAndPublish = (): void => {
|
||||
trackStartRequested.resolve();
|
||||
joinAndPublishRequested$.next(true);
|
||||
};
|
||||
|
||||
const requestDisconnect = (): void => {
|
||||
joinAndPublishRequested$.next(false);
|
||||
};
|
||||
|
||||
// Take care of the publisher$
|
||||
// create a new one as soon as a local Connection is available
|
||||
//
|
||||
// Recreate a new one once the local connection changes
|
||||
// - stop publishing
|
||||
// - destruct all current streams
|
||||
// - overwrite current publisher
|
||||
scope.reconcile(localConnection$, async (connection) => {
|
||||
logger.info(
|
||||
"reconcile based on new localConnection:",
|
||||
connection?.transport.livekit_service_url,
|
||||
);
|
||||
if (connection !== null) {
|
||||
const publisher = createPublisherFactory(connection);
|
||||
publisher$.next(publisher);
|
||||
|
||||
// Clean-up callback
|
||||
return Promise.resolve(async (): Promise<void> => {
|
||||
await publisher.destroy();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Use reconcile here to not run concurrent createAndSetupTracks calls
|
||||
// `tracks$` will update once they are ready.
|
||||
scope.reconcile(
|
||||
scope.behavior(
|
||||
combineLatest([
|
||||
publisher$ /*, tracks$*/,
|
||||
from(trackStartRequested.promise),
|
||||
]),
|
||||
null,
|
||||
const {
|
||||
startTracks,
|
||||
requestJoinAndPublish,
|
||||
requestDisconnect,
|
||||
joinAndPublishRequested$,
|
||||
participant$,
|
||||
localConnectionState$,
|
||||
mediaState$,
|
||||
publishError$,
|
||||
sharingScreen$,
|
||||
toggleScreenSharing,
|
||||
screenShareError$,
|
||||
dismissScreenShareError,
|
||||
} = createLocalMedia$({
|
||||
scope,
|
||||
localConnection$,
|
||||
transportReady$: scope.behavior(
|
||||
activeTransport$.pipe(map((transport) => transport !== null)),
|
||||
),
|
||||
async (valueIfReady) => {
|
||||
if (!valueIfReady) return;
|
||||
const [publisher] = valueIfReady;
|
||||
if (publisher) {
|
||||
await publisher.createAndSetupTracks().catch((e) => logger.error(e));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Based on `connectRequested$` we start publishing tracks. (once they are there!)
|
||||
scope.reconcile(
|
||||
scope.behavior(combineLatest([publisher$, joinAndPublishRequested$])),
|
||||
async ([publisher, shouldJoinAndPublish]) => {
|
||||
// Get the current publishing state to avoid redundant calls.
|
||||
const isPublishing = publisher?.shouldPublish === true;
|
||||
if (shouldJoinAndPublish && !isPublishing) {
|
||||
try {
|
||||
await publisher?.startPublishing();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
setPublishError(new FailToStartLivekitConnection(message));
|
||||
}
|
||||
} else if (isPublishing) {
|
||||
try {
|
||||
await publisher?.stopPublishing();
|
||||
} catch (error) {
|
||||
setPublishError(new UnknownCallError(error as Error));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
matrixConnected$: homeserverConnected.combined$.pipe(
|
||||
map(([connected]) => connected),
|
||||
),
|
||||
createPublisherFactory,
|
||||
hideScreensharing,
|
||||
hostBridge,
|
||||
logger,
|
||||
});
|
||||
|
||||
// STATE COMPUTATION
|
||||
|
||||
// These are non fatal since we can join a room and concume media even though publishing failed.
|
||||
const publishError$ = new BehaviorSubject<ElementCallError | null>(null);
|
||||
const setPublishError = (e: ElementCallError): void => {
|
||||
if (publishError$.value !== null) {
|
||||
logger.error("Multiple Media Errors:", e);
|
||||
} else {
|
||||
publishError$.next(e);
|
||||
}
|
||||
};
|
||||
|
||||
const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>(
|
||||
null,
|
||||
);
|
||||
@@ -502,48 +371,6 @@ export const createLocalMembership$ = ({
|
||||
}
|
||||
};
|
||||
|
||||
const localConnectionState$ = localConnection$.pipe(
|
||||
switchMap((connection) => (connection ? connection.state$ : of(null))),
|
||||
);
|
||||
|
||||
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
|
||||
combineLatest([
|
||||
localConnectionState$,
|
||||
activeTransport$,
|
||||
joinAndPublishRequested$,
|
||||
from(trackStartRequested.promise).pipe(
|
||||
map(() => true),
|
||||
startWith(false),
|
||||
),
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
localConnectionState,
|
||||
localTransport,
|
||||
shouldPublish,
|
||||
shouldStartTracks,
|
||||
]) => {
|
||||
if (!localTransport) return null;
|
||||
const trackState: TrackState = shouldStartTracks
|
||||
? TrackState.Ready
|
||||
: TrackState.WaitingForUser;
|
||||
|
||||
if (
|
||||
localConnectionState !== ConnectionState.LivekitConnected ||
|
||||
trackState !== TrackState.Ready
|
||||
)
|
||||
return {
|
||||
connection: localConnectionState,
|
||||
tracks: trackState,
|
||||
};
|
||||
if (!shouldPublish) return PublishState.WaitingForUser;
|
||||
// if (!publishing) return PublishState.Starting;
|
||||
return PublishState.Publishing;
|
||||
},
|
||||
),
|
||||
distinctUntilChanged(deepCompare),
|
||||
),
|
||||
);
|
||||
const fatalMatrixError$ = new BehaviorSubject<ElementCallError | null>(null);
|
||||
const setMatrixError = (e: ElementCallError): void => {
|
||||
if (fatalMatrixError$.value !== null) {
|
||||
@@ -650,27 +477,6 @@ export const createLocalMembership$ = ({
|
||||
}
|
||||
});
|
||||
|
||||
// inform the host about the connect and disconnect intent from the user.
|
||||
scope
|
||||
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
|
||||
undefined,
|
||||
joinAndPublishRequested$.value,
|
||||
])
|
||||
.subscribe(([prev, current]) => {
|
||||
// JOIN prev=false (was left) => current-true (now joiend)
|
||||
if (!prev && current) {
|
||||
hostBridge.notifyJoined().catch((e) => {
|
||||
logger.error("Failed to notify the host that we joined", e);
|
||||
});
|
||||
}
|
||||
// LEAVE prev=false (was joined) => current-true (now left)
|
||||
if (prev && !current) {
|
||||
hostBridge.notifyHungUp().catch((e) => {
|
||||
logger.error("Failed to notify the host that we hung up", e);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
muteStates.video.enabled$.pipe(scope.bind()).subscribe((videoEnabled) => {
|
||||
void matrixRTCSession
|
||||
.updateCallIntent(videoEnabled ? "video" : "audio")
|
||||
@@ -718,15 +524,6 @@ export const createLocalMembership$ = ({
|
||||
},
|
||||
);
|
||||
|
||||
const participant$ = scope.behavior(
|
||||
localConnection$.pipe(
|
||||
map((c) => c?.livekitRoom?.localParticipant ?? null),
|
||||
tap((p) => {
|
||||
logger.debug("participant$ updated:", p?.identity);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Delegate delayed leaves to the SFU
|
||||
scope.reconcile(
|
||||
scope.behavior(combineLatest([joinParams$, delayId$])),
|
||||
@@ -755,151 +552,6 @@ export const createLocalMembership$ = ({
|
||||
},
|
||||
);
|
||||
|
||||
// Pause upstream of all local media tracks when we're disconnected from
|
||||
// MatrixRTC, because it can be an unpleasant surprise for the app to say
|
||||
// 'reconnecting' and yet still be transmitting your media to others.
|
||||
// We use matrixConnected$ rather than reconnecting$ because we want to
|
||||
// pause tracks during the initial joining sequence too until we're sure
|
||||
// that our own media is displayed on screen.
|
||||
// TODO refactor this based no livekitState$
|
||||
combineLatest([participant$, homeserverConnected.combined$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([participant, [connected]]) => {
|
||||
if (!participant) return;
|
||||
const publications = participant.trackPublications.values();
|
||||
if (connected) {
|
||||
for (const p of publications) {
|
||||
if (p.track?.isUpstreamPaused === true) {
|
||||
const kind = p.track.kind;
|
||||
logger.info(
|
||||
`Resuming ${kind} track (MatrixRTC connection present)`,
|
||||
);
|
||||
p.track
|
||||
.resumeUpstream()
|
||||
.catch((e) =>
|
||||
logger.error(
|
||||
`Failed to resume ${kind} track after MatrixRTC reconnection`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const p of publications) {
|
||||
if (p.track?.isUpstreamPaused === false) {
|
||||
const kind = p.track.kind;
|
||||
logger.info(
|
||||
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
|
||||
);
|
||||
p.track
|
||||
.pauseUpstream()
|
||||
.catch((e) =>
|
||||
logger.error(
|
||||
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
|
||||
e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the user is currently sharing their screen.
|
||||
*/
|
||||
const sharingScreen$ = scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) => (p !== null ? observeSharingScreen$(p) : of(false))),
|
||||
),
|
||||
);
|
||||
|
||||
const screenShareError$ = new BehaviorSubject<Error | null>(null);
|
||||
let toggleScreenSharing: (() => void) | null = null;
|
||||
if (
|
||||
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
|
||||
!hideScreensharing
|
||||
) {
|
||||
toggleScreenSharing = (): void => {
|
||||
const screenshareSettings: ScreenShareCaptureOptions = {
|
||||
// Screen share audio shouldn't have any filtering.
|
||||
// "echoCancellation" is purposely excluded, as setting it to
|
||||
// false causes the screen share audio track to include
|
||||
// an echo of the incoming participant's voice
|
||||
audio: {
|
||||
autoGainControl: false,
|
||||
noiseSuppression: false,
|
||||
voiceIsolation: false,
|
||||
},
|
||||
selfBrowserSurface: "include",
|
||||
surfaceSwitching: "include",
|
||||
systemAudio: "include",
|
||||
};
|
||||
|
||||
let publishOptions: TrackPublishOptions | undefined;
|
||||
|
||||
if (advancedScreenShare.getValue()) {
|
||||
// User has advanced screen share settings enabled
|
||||
const { width, height } = parseResolution(
|
||||
screenShareResolution.getValue(),
|
||||
);
|
||||
const fps = screenShareFramerate.getValue();
|
||||
const bps = screenShareBitrate.getValue();
|
||||
const codec = screenShareCodec.getValue();
|
||||
|
||||
screenshareSettings.resolution = {
|
||||
width,
|
||||
height,
|
||||
frameRate: fps,
|
||||
};
|
||||
|
||||
publishOptions = {
|
||||
screenShareEncoding: {
|
||||
maxBitrate: bps,
|
||||
maxFramerate: fps,
|
||||
},
|
||||
videoCodec: codec,
|
||||
};
|
||||
} else {
|
||||
// Fall back to config.json settings if available
|
||||
const screenConf = Config.get().media_quality?.screen_share;
|
||||
if (screenConf?.max_resolution) {
|
||||
screenshareSettings.resolution = {
|
||||
width: Math.round((screenConf.max_resolution * 16) / 9),
|
||||
height: screenConf.max_resolution,
|
||||
frameRate: screenConf.max_framerate ?? 30,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const targetScreenshareState = !sharingScreen$.value;
|
||||
logger.info(
|
||||
`toggleScreenSharing called. Switching ${
|
||||
targetScreenshareState ? "On" : "Off"
|
||||
}`,
|
||||
);
|
||||
// If a connection is ready, toggle screen sharing.
|
||||
// We deliberately do nothing in the case of a null connection because
|
||||
// it looks nice for the call control buttons to all become available
|
||||
// at once upon joining the call, rather than introducing a disabled
|
||||
// state. The user can just click again.
|
||||
// We also allow screen sharing to be toggled even if the connection
|
||||
// is still initializing or publishing tracks, because there's no
|
||||
// technical reason to disallow this. LiveKit will publish if it can.
|
||||
const participant = participant$.value;
|
||||
if (!participant) return;
|
||||
watchScreenShareToggle(
|
||||
participant.setScreenShareEnabled(
|
||||
targetScreenshareState,
|
||||
screenshareSettings,
|
||||
publishOptions,
|
||||
),
|
||||
targetScreenshareState,
|
||||
logger,
|
||||
(e) => screenShareError$.next(e),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
startTracks,
|
||||
requestJoinAndPublish,
|
||||
@@ -916,53 +568,12 @@ export const createLocalMembership$ = ({
|
||||
sharingScreen$,
|
||||
toggleScreenSharing,
|
||||
screenShareError$,
|
||||
dismissScreenShareError: () => screenShareError$.next(null),
|
||||
dismissScreenShareError,
|
||||
connection$: localConnection$,
|
||||
internalLoggerRef: logger,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Logs the outcome of a screen share toggle and reports failures.
|
||||
*
|
||||
* getDisplayMedia may legitimately take a long time (the user is choosing
|
||||
* what to share) or never settle at all, so nothing is inferred from silence:
|
||||
* the request and its completion are logged with the elapsed time so that a
|
||||
* hang is visible in the logs, and only an explicit rejection is reported.
|
||||
*
|
||||
* The user cancelling the picker rejects with a NotAllowedError; that is
|
||||
* logged but not reported.
|
||||
*/
|
||||
export function watchScreenShareToggle(
|
||||
toggle: Promise<unknown>,
|
||||
enable: boolean,
|
||||
logger: Logger,
|
||||
onError: (e: Error) => void,
|
||||
): void {
|
||||
const what = `Screen share ${enable ? "start" : "stop"}`;
|
||||
const started = Date.now();
|
||||
const elapsed = (): string => `${Date.now() - started} ms`;
|
||||
logger.info(`${what} requested`);
|
||||
toggle.then(
|
||||
() => logger.info(`${what} completed in ${elapsed()}`),
|
||||
(e: unknown) => {
|
||||
logger.error(`${what} failed after ${elapsed()}:`, e);
|
||||
if (e instanceof DOMException && e.name === "NotAllowedError") return;
|
||||
onError(e instanceof Error ? e : new Error(String(e)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function observeSharingScreen$(p: Participant): Observable<boolean> {
|
||||
return observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.TrackPublished,
|
||||
ParticipantEvent.TrackUnpublished,
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
ParticipantEvent.LocalTrackUnpublished,
|
||||
).pipe(map((p) => p.isScreenShareEnabled));
|
||||
}
|
||||
|
||||
interface EnterRTCSessionOptions {
|
||||
encryptMedia: boolean;
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
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 {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
type Observable,
|
||||
pairwise,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../../Behavior.ts";
|
||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||
import { type MuteStates } from "../../MuteStates.ts";
|
||||
import { type HostBridge } from "../../../HostBridge.ts";
|
||||
import {
|
||||
ElementCallError,
|
||||
MembershipManagerError,
|
||||
} from "../../../utils/errors.ts";
|
||||
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
||||
import {
|
||||
FfiImpairment,
|
||||
FfiKeepAlive,
|
||||
FfiStatus,
|
||||
type FfiConnectionWithMembers,
|
||||
type FfiJoinParams,
|
||||
type FfiStatus as FfiStatusType,
|
||||
type FfiTransportIntent,
|
||||
} from "../../../matrix-rtc-sdk";
|
||||
import { type SlotPolicy } from "../../rtc/CallParticipation.ts";
|
||||
import { type DisconnectContext, errorForStatus } from "../../rtc/errors.ts";
|
||||
import { publishOnLivekit } from "../../rtc/transportIntent.ts";
|
||||
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
|
||||
import {
|
||||
ConnectionState,
|
||||
type Connection,
|
||||
} from "../remoteMembers/Connection.ts";
|
||||
import { type Publisher } from "./Publisher.ts";
|
||||
import { createLocalMedia$ } from "./LocalMedia.ts";
|
||||
import {
|
||||
type LocalMembership,
|
||||
type LocalMemberState,
|
||||
MatrixConnectionStatus,
|
||||
TransportState,
|
||||
} from "./LocalMember.ts";
|
||||
import { type HomeserverDisconnectReason } from "./HomeserverConnected.ts";
|
||||
|
||||
/** What our own membership needs from a {@link CallParticipation}. */
|
||||
export interface ParticipationLocalMemberSource {
|
||||
status$: Behavior<FfiStatusType>;
|
||||
connections$: Behavior<FfiConnectionWithMembers[]>;
|
||||
ownMemberId$: Behavior<string | null>;
|
||||
join(
|
||||
intent: FfiTransportIntent,
|
||||
params: FfiJoinParams,
|
||||
slot: SlotPolicy,
|
||||
): Promise<void>;
|
||||
leave(): Promise<void>;
|
||||
updateApplication(intent: string | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
participation: ParticipationLocalMemberSource;
|
||||
connectionManager: IConnectionManager;
|
||||
createPublisherFactory: (connection: Connection) => Publisher;
|
||||
muteStates: MuteStates;
|
||||
/** Whether to hide the screen-sharing button. */
|
||||
hideScreensharing: boolean;
|
||||
/** The application hosting Element Call, to be kept informed of join/leave. */
|
||||
hostBridge: HostBridge;
|
||||
/** How to join, from the configuration. */
|
||||
joinParams: FfiJoinParams;
|
||||
/** Whether the room's slot may be opened by us, and how. */
|
||||
slotPolicy$: Behavior<SlotPolicy>;
|
||||
/**
|
||||
* A developer's own LiveKit service URL to publish on instead of whatever
|
||||
* the homeserver advertises; null or empty for none.
|
||||
*/
|
||||
customLivekitUrl$: Observable<string | null | undefined>;
|
||||
/** For turning a failed participation into the error the UI shows. */
|
||||
disconnectContext: () => DisconnectContext;
|
||||
/** The room, as the call identifier in analytics events. */
|
||||
roomId: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Matrix side of the call as the crate sees it, reduced to what the local
|
||||
* member state needs: connected or not, and if not, why.
|
||||
*/
|
||||
interface MatrixConnection {
|
||||
connected: boolean;
|
||||
reason: HomeserverDisconnectReason | null;
|
||||
status: MatrixConnectionStatus;
|
||||
}
|
||||
|
||||
function describeStatus(status: FfiStatusType): MatrixConnection {
|
||||
if (FfiStatus.Disconnected.instanceOf(status))
|
||||
return {
|
||||
connected: false,
|
||||
reason: "membership",
|
||||
status: MatrixConnectionStatus.Disconnected,
|
||||
};
|
||||
if (!FfiStatus.Connected.instanceOf(status))
|
||||
return {
|
||||
connected: false,
|
||||
reason: "membership",
|
||||
status: MatrixConnectionStatus.Connecting,
|
||||
};
|
||||
const { impairments, keepAlive } = status.inner;
|
||||
if (
|
||||
impairments.some((i) => FfiImpairment.HomeserverUnreachable.instanceOf(i))
|
||||
)
|
||||
return {
|
||||
connected: false,
|
||||
reason: "sync",
|
||||
status: MatrixConnectionStatus.Connected,
|
||||
};
|
||||
// A keep-alive that cannot be restarted may already have fired: the
|
||||
// homeserver may think we left.
|
||||
if (
|
||||
FfiKeepAlive.RestartFailing.instanceOf(keepAlive) ||
|
||||
FfiKeepAlive.Expired.instanceOf(keepAlive)
|
||||
)
|
||||
return {
|
||||
connected: false,
|
||||
reason: "probablyLeft",
|
||||
status: MatrixConnectionStatus.Connected,
|
||||
};
|
||||
return {
|
||||
connected: true,
|
||||
reason: null,
|
||||
status: MatrixConnectionStatus.Connected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Our own membership over a {@link CallParticipation}: the crate publishes
|
||||
* and keeps alive the membership, discovers the transport and mints its
|
||||
* token; this joins and leaves when the user asks, publishes our media on
|
||||
* the connection the crate gave us, and projects the crate's status onto the
|
||||
* connected / reconnecting / error states the call UI shows.
|
||||
*/
|
||||
export const createParticipationLocalMembership$ = ({
|
||||
scope,
|
||||
participation,
|
||||
connectionManager,
|
||||
createPublisherFactory,
|
||||
muteStates,
|
||||
hideScreensharing,
|
||||
hostBridge,
|
||||
joinParams,
|
||||
slotPolicy$,
|
||||
customLivekitUrl$,
|
||||
disconnectContext,
|
||||
roomId,
|
||||
logger: parentLogger,
|
||||
}: Props): LocalMembership => {
|
||||
const logger = parentLogger.getChild("[ParticipationLocalMembership]");
|
||||
logger.debug(`Creating local membership..`);
|
||||
|
||||
// The connection we publish on: the one the crate lists our own member on.
|
||||
const ownServiceUrl$ = scope.behavior(
|
||||
combineLatest([
|
||||
participation.connections$,
|
||||
participation.ownMemberId$,
|
||||
]).pipe(
|
||||
map(
|
||||
([connections, ownMemberId]) =>
|
||||
connections.find((c) =>
|
||||
c.members.some((m) => m.memberId === ownMemberId),
|
||||
)?.connection.serviceUrl ?? null,
|
||||
),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
);
|
||||
|
||||
const localConnection$ = scope.behavior(
|
||||
combineLatest([
|
||||
connectionManager.connectionManagerData$,
|
||||
ownServiceUrl$,
|
||||
]).pipe(
|
||||
map(([{ value: connectionData }, serviceUrl]) =>
|
||||
serviceUrl === null
|
||||
? null
|
||||
: connectionData.getConnectionForTransport({
|
||||
type: "livekit",
|
||||
livekit_service_url: serviceUrl,
|
||||
}),
|
||||
),
|
||||
tap((connection) => {
|
||||
logger.info(
|
||||
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const matrixConnection$ = scope.behavior(
|
||||
participation.status$.pipe(
|
||||
map(describeStatus),
|
||||
distinctUntilChanged(
|
||||
(a, b) =>
|
||||
a.connected === b.connected &&
|
||||
a.reason === b.reason &&
|
||||
a.status === b.status,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const {
|
||||
startTracks,
|
||||
requestJoinAndPublish,
|
||||
requestDisconnect,
|
||||
joinAndPublishRequested$,
|
||||
participant$,
|
||||
localConnectionState$,
|
||||
mediaState$,
|
||||
publishError$,
|
||||
sharingScreen$,
|
||||
toggleScreenSharing,
|
||||
screenShareError$,
|
||||
dismissScreenShareError,
|
||||
} = createLocalMedia$({
|
||||
scope,
|
||||
localConnection$,
|
||||
transportReady$: scope.behavior(
|
||||
ownServiceUrl$.pipe(map((url) => url !== null)),
|
||||
),
|
||||
matrixConnected$: matrixConnection$.pipe(map((c) => c.connected)),
|
||||
createPublisherFactory,
|
||||
hideScreensharing,
|
||||
hostBridge,
|
||||
logger,
|
||||
});
|
||||
|
||||
// MATRIX RELATED
|
||||
|
||||
const fatalMatrixError$ = new BehaviorSubject<ElementCallError | null>(null);
|
||||
const setMatrixError = (e: ElementCallError): void => {
|
||||
if (fatalMatrixError$.value !== null) {
|
||||
logger.error("Multiple Matrix Errors:", e);
|
||||
} else {
|
||||
fatalMatrixError$.next(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Join and leave as the user asks. The crate does the rest: it opens the
|
||||
// slot when we may, discovers the transport, publishes and keeps the
|
||||
// membership alive, and delegates the delayed leave.
|
||||
scope.reconcile(
|
||||
scope.behavior(
|
||||
combineLatest([joinAndPublishRequested$, customLivekitUrl$]),
|
||||
),
|
||||
async ([shouldConnect, customLivekitUrl]) => {
|
||||
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration.
|
||||
if (!shouldConnect) return;
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||
PosthogAnalytics.instance.eventCallStarted.track(roomId);
|
||||
try {
|
||||
await participation.join(
|
||||
publishOnLivekit(customLivekitUrl || undefined),
|
||||
joinParams,
|
||||
slotPolicy$.value,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error joining the session", error);
|
||||
setMatrixError(
|
||||
error instanceof ElementCallError
|
||||
? error
|
||||
: (errorForStatus(
|
||||
participation.status$.value,
|
||||
disconnectContext(),
|
||||
) ??
|
||||
new MembershipManagerError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(async (): Promise<void> => {
|
||||
try {
|
||||
await participation.leave();
|
||||
} catch (e) {
|
||||
logger.error("Error leaving the session", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// The crate can end the participation on its own (the slot closed, the
|
||||
// manager stopped): while the user still wants to be in the call, that is
|
||||
// an error to show.
|
||||
combineLatest([participation.status$, joinAndPublishRequested$])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([status, shouldConnect]) => {
|
||||
if (!shouldConnect) return;
|
||||
const error = errorForStatus(status, disconnectContext());
|
||||
if (error !== null && fatalMatrixError$.value === null) {
|
||||
logger.warn("The participation ended on its own", error);
|
||||
setMatrixError(error);
|
||||
}
|
||||
});
|
||||
|
||||
const localMemberState$ = scope.behavior<LocalMemberState>(
|
||||
combineLatest([
|
||||
mediaState$,
|
||||
matrixConnection$,
|
||||
fatalMatrixError$,
|
||||
publishError$,
|
||||
]).pipe(
|
||||
map(([mediaState, matrixConnection, fatalMatrixError, publishError]) => {
|
||||
// `mediaState` will be 'null' until the transport/connection appears.
|
||||
if (mediaState)
|
||||
return {
|
||||
matrix: fatalMatrixError ?? matrixConnection.status,
|
||||
media: publishError ?? mediaState,
|
||||
};
|
||||
// A join that failed before any transport was known is still fatal.
|
||||
if (fatalMatrixError) return fatalMatrixError;
|
||||
return TransportState.Waiting;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The disconnect reason for the combined Matrix + LiveKit connection, or null
|
||||
* when fully connected. Homeserver reasons take priority over livekit.
|
||||
*/
|
||||
const connectionDisconnectReason$ = scope.behavior(
|
||||
combineLatest([
|
||||
matrixConnection$,
|
||||
localConnectionState$.pipe(
|
||||
map((state) => state === ConnectionState.LivekitConnected),
|
||||
),
|
||||
]).pipe(
|
||||
map(([matrix, livekitConnected]) => {
|
||||
if (!matrix.connected) return matrix.reason!;
|
||||
if (!livekitConnected) return "livekit" as const;
|
||||
return null;
|
||||
}),
|
||||
tap((v) => logger.debug("livekit+matrix: Connected state changed", v)),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether we are "fully" connected to the call. Accounts for both the
|
||||
* connection to the MatrixRTC session and the LiveKit publish connection.
|
||||
*/
|
||||
const matrixAndLivekitConnected$ = scope.behavior(
|
||||
connectionDisconnectReason$.pipe(map((reason) => reason === null)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether we should tell the user that we're reconnecting to the call.
|
||||
*/
|
||||
const reconnecting$ = scope.behavior(
|
||||
matrixAndLivekitConnected$.pipe(
|
||||
pairwise(),
|
||||
map(([prev, current]) => prev === true && current === false),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
let reconnectStart: {
|
||||
time: number;
|
||||
reason: NonNullable<(typeof connectionDisconnectReason$)["value"]>;
|
||||
} | null = null;
|
||||
connectionDisconnectReason$
|
||||
.pipe(distinctUntilChanged(), pairwise(), scope.bind())
|
||||
.subscribe(([prev, reason]) => {
|
||||
if (reason !== null) {
|
||||
// Only begin tracking when transitioning FROM connected (null → non-null).
|
||||
if (prev === null) {
|
||||
reconnectStart ??= { time: Date.now(), reason };
|
||||
}
|
||||
} else if (reconnectStart !== null) {
|
||||
PosthogAnalytics.instance.eventCallReconnecting.track(
|
||||
roomId,
|
||||
reconnectStart.reason,
|
||||
(Date.now() - reconnectStart.time) / 1000,
|
||||
);
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheReconnecting(
|
||||
reconnectStart.reason,
|
||||
);
|
||||
reconnectStart = null;
|
||||
}
|
||||
});
|
||||
|
||||
// The call intent follows the camera (C11). Before the join the crate
|
||||
// refuses, which is expected.
|
||||
muteStates.video.enabled$.pipe(scope.bind()).subscribe((videoEnabled) => {
|
||||
participation
|
||||
.updateApplication(videoEnabled ? "video" : "audio")
|
||||
.catch((e) => {
|
||||
logger.debug(
|
||||
"Could not update the call intent (expected before the join)",
|
||||
e,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
startTracks,
|
||||
requestJoinAndPublish,
|
||||
requestDisconnect,
|
||||
localMemberState$,
|
||||
participant$,
|
||||
reconnecting$,
|
||||
connected$: matrixAndLivekitConnected$,
|
||||
disconnected$: scope.behavior(
|
||||
matrixConnection$.pipe(
|
||||
map((c) => c.status === MatrixConnectionStatus.Disconnected),
|
||||
),
|
||||
),
|
||||
sharingScreen$,
|
||||
toggleScreenSharing,
|
||||
screenShareError$,
|
||||
dismissScreenShareError,
|
||||
connection$: localConnection$,
|
||||
internalLoggerRef: logger,
|
||||
};
|
||||
};
|
||||
@@ -53,8 +53,12 @@ export interface ConnectionOpts {
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/** The media transport to connect to. */
|
||||
transport: LivekitTransportConfig;
|
||||
/** The Matrix client to use for OpenID and SFU config requests. */
|
||||
client: OpenIDClientParts;
|
||||
/**
|
||||
* The Matrix client to use for OpenID and SFU config requests. `null` when
|
||||
* every connection comes with its token already (the crate mints them), in
|
||||
* which case a connection without `existingSFUConfig` cannot start.
|
||||
*/
|
||||
client: OpenIDClientParts | null;
|
||||
/** The room ID this connection is associated with. */
|
||||
roomId: string;
|
||||
/** The observable scope to use for this connection. */
|
||||
@@ -137,7 +141,7 @@ export class Connection {
|
||||
protected stopped = false;
|
||||
|
||||
// TODO: can we just keep the ConnectionOpts object instead of spreading?
|
||||
private readonly client: OpenIDClientParts;
|
||||
private readonly client: OpenIDClientParts | null;
|
||||
private readonly roomId: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
@@ -398,6 +402,10 @@ export class Connection {
|
||||
protected async getSFUConfigForRemoteConnection(): Promise<SFUConfig> {
|
||||
// This will only be called for sfu's where we do not publish ourselves.
|
||||
// For the local connection we will use the existingJwtTokenData
|
||||
if (this.client === null)
|
||||
throw new FailedToStartError(
|
||||
"No token for this connection and no client to fetch one with",
|
||||
);
|
||||
return await getSFUConfigWithOpenID(
|
||||
this.client,
|
||||
this.ownMembershipIdentity,
|
||||
|
||||
@@ -57,7 +57,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
/**
|
||||
* Creates a ConnectionFactory for LiveKit connections.
|
||||
*
|
||||
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
|
||||
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens. `null` when every connection is created with its token (the crate mints them).
|
||||
* @param roomId - The current room ID.
|
||||
* @param devices - Used for video/audio out/in capture options.
|
||||
* @param processorState$ - Effects like background blur (only for publishing connection?)
|
||||
@@ -66,7 +66,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
|
||||
*/
|
||||
public constructor(
|
||||
private client: OpenIDClientParts,
|
||||
private client: OpenIDClientParts | null,
|
||||
private readonly roomId: string,
|
||||
private devices: MediaDevices,
|
||||
private processorState$: Behavior<ProcessorState>,
|
||||
|
||||
@@ -31,8 +31,26 @@ export type TaggedParticipant =
|
||||
| LocalTaggedParticipant
|
||||
| RemoteTaggedParticipant;
|
||||
|
||||
/**
|
||||
* What a tile needs to know about a call member, whichever MatrixRTC
|
||||
* implementation produced it: matrix-js-sdk's `CallMembership` has this shape,
|
||||
* and the crate's `FfiMembership` is projected onto it.
|
||||
*/
|
||||
export interface CallMember {
|
||||
userId: string;
|
||||
/**
|
||||
* The member's device. The crate does not always know it (an unencrypted
|
||||
* room, a pre-sticky event); the projection then falls back to the member
|
||||
* id, which keeps the `${userId}:${deviceId}` media ids unique.
|
||||
*/
|
||||
deviceId: string;
|
||||
memberId: string;
|
||||
/** The LiveKit participant identity, once known. */
|
||||
rtcBackendIdentity: string | undefined;
|
||||
}
|
||||
|
||||
export interface MatrixLivekitMember {
|
||||
membership$: Behavior<CallMembership>;
|
||||
membership$: Behavior<CallMember>;
|
||||
connection$: Behavior<Connection | null>;
|
||||
// participantId: string; We do not want a participantId here since it will be generated by the jwt
|
||||
// TODO decide if we can also drop the userId. Its in the matrix membership anyways.
|
||||
|
||||
@@ -109,6 +109,11 @@ export const memberDisplaynames$ = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Per-member display names and avatars, disambiguated over the call. */
|
||||
export type MatrixMemberMetadata = ReturnType<
|
||||
typeof createMatrixMemberMetadata$
|
||||
>;
|
||||
|
||||
export const createMatrixMemberMetadata$ = (
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
|
||||
|
||||
@@ -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 { describe, expect, it } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
MockConnection,
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
testScope,
|
||||
} from "../../../utils/test";
|
||||
import {
|
||||
FakeParticipation,
|
||||
fakeConnection,
|
||||
} from "../../../utils/test-participation";
|
||||
import { type ConnectionFactory } from "./ConnectionFactory";
|
||||
import { createParticipationConnectionManager$ } from "./ParticipationConnections";
|
||||
|
||||
interface Created {
|
||||
serviceUrl: string;
|
||||
jwt: string | undefined;
|
||||
url: string | undefined;
|
||||
}
|
||||
|
||||
function recordingFactory(): {
|
||||
factory: ConnectionFactory;
|
||||
created: Created[];
|
||||
} {
|
||||
const created: Created[] = [];
|
||||
const factory: ConnectionFactory = {
|
||||
createConnection(scope, transport, ownMembershipIdentity, logger, sfu) {
|
||||
created.push({
|
||||
serviceUrl: transport.livekit_service_url,
|
||||
jwt: sfu?.jwt,
|
||||
url: sfu?.url,
|
||||
});
|
||||
return new MockConnection(
|
||||
{
|
||||
scope,
|
||||
transport,
|
||||
ownMembershipIdentity,
|
||||
existingSFUConfig: sfu,
|
||||
client: null,
|
||||
roomId: "!room:example.org",
|
||||
livekitRoomFactory: () =>
|
||||
mockLivekitRoom({
|
||||
localParticipant: mockLocalParticipant({ identity: "" }),
|
||||
remoteParticipants: new Map(),
|
||||
}),
|
||||
},
|
||||
logger,
|
||||
);
|
||||
},
|
||||
};
|
||||
return { factory, created };
|
||||
}
|
||||
|
||||
describe("createParticipationConnectionManager$", () => {
|
||||
it("opens one connection per service with the crate's token and keeps it across a refresh", () => {
|
||||
const scope = testScope();
|
||||
const participation = new FakeParticipation();
|
||||
const { factory, created } = recordingFactory();
|
||||
const manager = createParticipationConnectionManager$({
|
||||
scope,
|
||||
participation,
|
||||
connectionFactory: factory,
|
||||
ownIdentity: { userId: "@me:example.org", deviceId: "MYDEV" },
|
||||
logger,
|
||||
});
|
||||
expect(manager.connectionManagerData$.value.value.getConnections()).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
participation.connections$.next([
|
||||
fakeConnection({ serviceUrl: "https://a", jwtToken: "t1" }),
|
||||
]);
|
||||
expect(created).toEqual([
|
||||
{ serviceUrl: "https://a", jwt: "t1", url: "wss://a" },
|
||||
]);
|
||||
const data = manager.connectionManagerData$.value.value;
|
||||
expect(data.getConnections()).toHaveLength(1);
|
||||
expect(
|
||||
data.getConnectionForTransport({
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://a",
|
||||
})?.transport.livekit_service_url,
|
||||
).toBe("https://a");
|
||||
|
||||
// The crate refreshed the token: the same connection stays up.
|
||||
participation.connections$.next([
|
||||
fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }),
|
||||
]);
|
||||
expect(created).toHaveLength(1);
|
||||
|
||||
// A second service appears; the first is untouched.
|
||||
participation.connections$.next([
|
||||
fakeConnection({ serviceUrl: "https://a", jwtToken: "t2" }),
|
||||
fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }),
|
||||
]);
|
||||
expect(created.map((c) => c.serviceUrl)).toEqual([
|
||||
"https://a",
|
||||
"https://b",
|
||||
]);
|
||||
expect(
|
||||
manager.connectionManagerData$.value.value.getConnections(),
|
||||
).toHaveLength(2);
|
||||
|
||||
// Everybody left the first service: its connection goes away.
|
||||
participation.connections$.next([
|
||||
fakeConnection({ serviceUrl: "https://b", jwtToken: "t3" }),
|
||||
]);
|
||||
expect(
|
||||
manager.connectionManagerData$.value.value
|
||||
.getConnections()
|
||||
.map((c) => c.transport.livekit_service_url),
|
||||
).toEqual(["https://b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
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 { combineLatest, map, of, skip, switchMap } from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { Epoch, type ObservableScope, trackEpoch } from "../../ObservableScope";
|
||||
import { generateItemsWithEpoch } from "../../../utils/observable";
|
||||
import { type FfiConnectionWithMembers } from "../../../matrix-rtc-sdk";
|
||||
import { type ConnectionFactory } from "./ConnectionFactory";
|
||||
import {
|
||||
ConnectionManagerData,
|
||||
type IConnectionManager,
|
||||
} from "./ConnectionManager";
|
||||
|
||||
/** What this module needs from a {@link CallParticipation}. */
|
||||
export interface ParticipationConnectionsSource {
|
||||
/** The LiveKit rooms to hold, with a token for each, keyed by service URL. */
|
||||
connections$: Behavior<FfiConnectionWithMembers[]>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
participation: ParticipationConnectionsSource;
|
||||
connectionFactory: ConnectionFactory;
|
||||
/** Who we publish as. Connections only log it; the tokens come minted. */
|
||||
ownIdentity: { userId: string; deviceId: string };
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* One LiveKit connection per transport the crate says the session uses — ours
|
||||
* and everybody else's — each started with the token the crate minted for
|
||||
* it. The crate discovers the transports, mints and refreshes the tokens;
|
||||
* this only turns its list into live `Connection`s, keyed by service URL so a
|
||||
* refreshed token does not tear a connection down.
|
||||
*/
|
||||
export function createParticipationConnectionManager$({
|
||||
scope,
|
||||
participation,
|
||||
connectionFactory,
|
||||
ownIdentity,
|
||||
logger: parentLogger,
|
||||
}: Props): IConnectionManager {
|
||||
const logger = parentLogger.getChild("[ParticipationConnections]");
|
||||
|
||||
const connections$ = scope.behavior(
|
||||
participation.connections$.pipe(
|
||||
trackEpoch(),
|
||||
generateItemsWithEpoch(
|
||||
"ParticipationConnections connections$",
|
||||
function* (connections) {
|
||||
for (const { connection } of connections) {
|
||||
yield {
|
||||
keys: [connection.serviceUrl] as const,
|
||||
data: { wsUrl: connection.wsUrl, jwt: connection.jwtToken },
|
||||
};
|
||||
}
|
||||
},
|
||||
(scope, token$, serviceUrl) => {
|
||||
const { wsUrl, jwt } = token$.value;
|
||||
const connection = connectionFactory.createConnection(
|
||||
scope,
|
||||
{ type: "livekit", livekit_service_url: serviceUrl },
|
||||
{ ...ownIdentity, memberId: "" },
|
||||
logger,
|
||||
// The crate minted this; nobody asks the authorisation service
|
||||
// again. The alias and identity are in the token itself.
|
||||
{ url: wsUrl, jwt, livekitAlias: "", livekitIdentity: "" },
|
||||
);
|
||||
// A token the crate refreshed while we are connected is used on
|
||||
// the next full (re)connect; livekit-client keeps the session on
|
||||
// the token it connected with.
|
||||
// TODO: hand the new token to livekit-client when it can take one.
|
||||
token$.pipe(skip(1), scope.bind()).subscribe(() => {
|
||||
logger.info(
|
||||
`New token for ${serviceUrl}; it is used on the next connect`,
|
||||
);
|
||||
});
|
||||
// Start the connection immediately; its state$ tracks progress.
|
||||
void connection.start();
|
||||
return connection;
|
||||
},
|
||||
),
|
||||
),
|
||||
new Epoch([], -1),
|
||||
);
|
||||
|
||||
const connectionManagerData$ = scope.behavior(
|
||||
connections$.pipe(
|
||||
switchMap((connections) => {
|
||||
const epoch = connections.epoch;
|
||||
if (connections.value.length === 0)
|
||||
return of(new Epoch(new ConnectionManagerData(), epoch));
|
||||
return combineLatest(
|
||||
connections.value.map((connection) =>
|
||||
connection.remoteParticipants$.pipe(
|
||||
map((participants) => ({ connection, participants })),
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
map(
|
||||
(lists) =>
|
||||
new Epoch(
|
||||
lists.reduce((data, { connection, participants }) => {
|
||||
data.add(connection, participants);
|
||||
return data;
|
||||
}, new ConnectionManagerData(logger)),
|
||||
epoch,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
new Epoch(new ConnectionManagerData(), -1),
|
||||
);
|
||||
|
||||
return { connectionManagerData$ };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
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 { map } from "rxjs";
|
||||
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { type ObservableScope } from "../../ObservableScope";
|
||||
import { observeDriver } from "../../../driver/observe";
|
||||
import {
|
||||
type RoomDriver,
|
||||
type RoomMemberProfile,
|
||||
} from "../../../driver/ElementCallMatrixClientDriver";
|
||||
import { type RoomMemberMap } from "./MatrixMemberMetadata";
|
||||
|
||||
/** The room's roster as the display-name and ringing code reads it. */
|
||||
export function roomMemberMapOf(profiles: RoomMemberProfile[]): RoomMemberMap {
|
||||
return profiles.reduce((acc, profile) => {
|
||||
acc.set(profile.userId, {
|
||||
userId: profile.userId,
|
||||
rawDisplayName: profile.displayName ?? profile.userId,
|
||||
getMxcAvatarUrl: () => profile.avatarUrl ?? undefined,
|
||||
});
|
||||
return acc;
|
||||
}, new Map() as RoomMemberMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* The room's joined and invited members, from the client driver. Call
|
||||
* members' names come from the crate; this is for the people who are in the
|
||||
* room but not (yet) in the call, and for disambiguating names.
|
||||
*/
|
||||
export function createParticipationRoomMembers$(
|
||||
scope: ObservableScope,
|
||||
room: RoomDriver,
|
||||
): Behavior<RoomMemberMap> {
|
||||
return scope.behavior(
|
||||
observeDriver(
|
||||
scope,
|
||||
() => room.getRoomMembers(),
|
||||
(listener) => room.subscribeRoomMembers(listener),
|
||||
).pipe(map(roomMemberMapOf)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
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 } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
MockConnection,
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
mockRemoteParticipant,
|
||||
testScope,
|
||||
} from "../../../utils/test";
|
||||
import {
|
||||
FakeParticipation,
|
||||
fakeMembership,
|
||||
} from "../../../utils/test-participation";
|
||||
import { constant } from "../../Behavior";
|
||||
import { Epoch } from "../../ObservableScope";
|
||||
import { ConnectionManagerData } from "./ConnectionManager";
|
||||
import {
|
||||
callMemberOf,
|
||||
createParticipationRemoteMembers$,
|
||||
} from "./ParticipationMembers";
|
||||
|
||||
const LK = "https://lk.example.org";
|
||||
|
||||
describe("callMemberOf", () => {
|
||||
it("projects the crate's membership onto what a tile needs", () => {
|
||||
expect(
|
||||
callMemberOf(
|
||||
fakeMembership({
|
||||
member: { memberId: "m-1", userId: "@a:x", deviceId: "DEV" },
|
||||
transportIdentity: "lk-1",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
userId: "@a:x",
|
||||
deviceId: "DEV",
|
||||
memberId: "m-1",
|
||||
rtcBackendIdentity: "lk-1",
|
||||
});
|
||||
// No device known: the member id keeps the media id unique.
|
||||
expect(
|
||||
callMemberOf(
|
||||
fakeMembership({ member: { memberId: "m-2", deviceId: undefined } }),
|
||||
).deviceId,
|
||||
).toBe("m-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createParticipationRemoteMembers$", () => {
|
||||
it("lists everyone but us, with their connection and participant", () => {
|
||||
const scope = testScope();
|
||||
const participation = new FakeParticipation();
|
||||
participation.ownMemberId$.next("m-me");
|
||||
|
||||
const connection = new MockConnection(
|
||||
{
|
||||
scope,
|
||||
transport: { type: "livekit", livekit_service_url: LK },
|
||||
ownMembershipIdentity: {
|
||||
userId: "@me:x",
|
||||
deviceId: "MYDEV",
|
||||
memberId: "m-me",
|
||||
},
|
||||
client: null,
|
||||
roomId: "!room:x",
|
||||
livekitRoomFactory: () =>
|
||||
mockLivekitRoom({
|
||||
localParticipant: mockLocalParticipant({ identity: "lk-me" }),
|
||||
remoteParticipants: new Map(),
|
||||
}),
|
||||
},
|
||||
logger,
|
||||
);
|
||||
const peerParticipant = mockRemoteParticipant({ identity: "lk-peer" });
|
||||
const data = new ConnectionManagerData();
|
||||
data.add(connection, [peerParticipant]);
|
||||
|
||||
const members$ = createParticipationRemoteMembers$({
|
||||
scope,
|
||||
participation,
|
||||
connectionManager: {
|
||||
connectionManagerData$: constant(new Epoch(data, 1)),
|
||||
},
|
||||
});
|
||||
expect(members$.value.value).toEqual([]);
|
||||
|
||||
participation.setMemberships([
|
||||
fakeMembership({ member: { memberId: "m-me", userId: "@me:x" } }),
|
||||
fakeMembership({
|
||||
member: { memberId: "m-peer", userId: "@peer:x" },
|
||||
connections: [LK],
|
||||
transportIdentity: "lk-peer",
|
||||
}),
|
||||
// Not on LiveKit yet (no token minted for them): no participant.
|
||||
fakeMembership({
|
||||
member: { memberId: "m-late", userId: "@late:x" },
|
||||
connections: ["https://elsewhere.example.org"],
|
||||
transportIdentity: "lk-late",
|
||||
}),
|
||||
]);
|
||||
|
||||
const members = members$.value.value;
|
||||
expect(members.map((m) => m.membership$.value.memberId)).toEqual([
|
||||
"m-peer",
|
||||
"m-late",
|
||||
]);
|
||||
const [peer, late] = members;
|
||||
expect(peer.userId).toBe("@peer:x");
|
||||
expect(peer.connection$.value).toBe(connection);
|
||||
expect(peer.participant.value$.value).toBe(peerParticipant);
|
||||
expect(late.connection$.value).toBeNull();
|
||||
expect(late.participant.value$.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
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 { combineLatest, map } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { Epoch, type ObservableScope } from "../../ObservableScope";
|
||||
import { generateItemsWithEpoch } from "../../../utils/observable";
|
||||
import { type FfiMembership } from "../../../matrix-rtc-sdk";
|
||||
import { type IConnectionManager } from "./ConnectionManager";
|
||||
import {
|
||||
type CallMember,
|
||||
type RemoteMatrixLivekitMember,
|
||||
} from "./MatrixLivekitMembers";
|
||||
|
||||
/** What this module needs from a {@link CallParticipation}. */
|
||||
export interface ParticipationRoster {
|
||||
memberships$: Behavior<Epoch<FfiMembership[]>>;
|
||||
ownMemberId$: Behavior<string | null>;
|
||||
}
|
||||
|
||||
/** The crate's membership as a tile sees it. */
|
||||
export function callMemberOf(membership: FfiMembership): CallMember {
|
||||
const { member } = membership;
|
||||
return {
|
||||
userId: member.userId,
|
||||
deviceId: member.deviceId ?? member.memberId,
|
||||
memberId: member.memberId,
|
||||
rtcBackendIdentity: membership.transportIdentity,
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
participation: ParticipationRoster;
|
||||
connectionManager: IConnectionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote members of the call with their LiveKit side: the connection to
|
||||
* the transport they publish on and, once they are on it, their participant
|
||||
* — matched by the transport identity the crate derived for them.
|
||||
*/
|
||||
export function createParticipationRemoteMembers$({
|
||||
scope,
|
||||
participation,
|
||||
connectionManager,
|
||||
}: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
|
||||
return scope.behavior(
|
||||
combineLatest([
|
||||
participation.memberships$,
|
||||
participation.ownMemberId$,
|
||||
connectionManager.connectionManagerData$,
|
||||
]).pipe(
|
||||
map(
|
||||
([memberships, ownMemberId, data]) =>
|
||||
new Epoch(
|
||||
[memberships.value, ownMemberId, data.value] as const,
|
||||
memberships.epoch,
|
||||
),
|
||||
),
|
||||
generateItemsWithEpoch(
|
||||
"ParticipationRemoteMembers",
|
||||
function* ([memberships, ownMemberId, managerData]) {
|
||||
for (const membership of memberships) {
|
||||
const { member, transportIdentity } = membership;
|
||||
if (member.memberId === ownMemberId) continue;
|
||||
|
||||
// The crate lists the services a member publishes on; today a
|
||||
// member publishes on at most one.
|
||||
const serviceUrl = membership.connections[0];
|
||||
const transport =
|
||||
serviceUrl === undefined
|
||||
? null
|
||||
: { type: "livekit" as const, livekit_service_url: serviceUrl };
|
||||
const participants = transport
|
||||
? managerData.getParticipantsForTransport(transport)
|
||||
: [];
|
||||
const matches = participants.filter(
|
||||
(p) => p.identity === transportIdentity,
|
||||
);
|
||||
const participant = matches[0] ?? null;
|
||||
const connection = transport
|
||||
? managerData.getConnectionForTransport(transport)
|
||||
: null;
|
||||
if (matches.length > 1)
|
||||
logger.warn(
|
||||
`[ParticipationRemoteMembers] ${transportIdentity}: ${matches.length} LiveKit participants match (sids ${matches.map((p) => p.sid).join(", ")}), using ${participant?.sid}`,
|
||||
);
|
||||
|
||||
yield {
|
||||
// The member id is the key; the rest is there for the logs.
|
||||
keys: [
|
||||
member.memberId,
|
||||
member.userId,
|
||||
member.deviceId ?? "",
|
||||
transportIdentity ?? "",
|
||||
],
|
||||
data: {
|
||||
membership: callMemberOf(membership),
|
||||
participant,
|
||||
connection,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
(scope, data$, _memberId, userId, _deviceId, rtcBackendIdentity) => {
|
||||
const { participant$, ...rest } = scope.splitBehavior(data$);
|
||||
// Log whether the member could be matched to a LiveKit participant,
|
||||
// since a tile shows "waiting for media" for as long as it cannot.
|
||||
participant$.pipe(scope.bind()).subscribe((p) => {
|
||||
const url = data$.value.connection?.transport.livekit_service_url;
|
||||
logger.info(
|
||||
`[ParticipationRemoteMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"} on ${url ?? "no connection"}`,
|
||||
);
|
||||
});
|
||||
return {
|
||||
userId,
|
||||
participant: { type: "remote" as const, value$: participant$ },
|
||||
...rest,
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
new Epoch([], -1),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Full integration against the dev backend (`pnpm backend`: Synapse develop,
|
||||
* lk-jwt-service and LiveKit behind nginx with the dev TLS certificate)
|
||||
* through the js-sdk drivers: two real users in one encrypted room, two
|
||||
* participations, and every wire feature the crate relies on — transport
|
||||
* discovery, the slot state event, sticky member events, delayed events and
|
||||
* their delegation, the token exchange, Olm-encrypted media keys and
|
||||
* homeserver connectivity.
|
||||
*
|
||||
* Opt-in, because it needs the backend and takes a minute:
|
||||
*
|
||||
* MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 \
|
||||
* pnpm vitest run --project unit src/state/rtc/CallParticipation.backend.test.ts
|
||||
*
|
||||
* `HOMESERVER_URL` overrides the homeserver (default: the dev backend).
|
||||
*/
|
||||
|
||||
// The global `process` is vite-plugin-node-polyfills' browser shim, whose
|
||||
// `env` is empty; the real one comes from the module.
|
||||
import { env } from "node:process";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type MatrixClient,
|
||||
Method,
|
||||
Preset,
|
||||
type Room,
|
||||
SyncState,
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { MatrixRTCMode } from "../../config/ConfigOptions";
|
||||
import { JsSdkElementCallMatrixClientDriver } from "../../driver/jsSdk/JsSdkElementCallMatrixClientDriver";
|
||||
import { JsSdkRtcMatrixDriver } from "../../driver/jsSdk/JsSdkRtcMatrixDriver";
|
||||
import { waitFor } from "../../driver/MockRtcMatrixDriver";
|
||||
import {
|
||||
FfiDelegationRoute,
|
||||
FfiImpairment,
|
||||
FfiKeepAlive,
|
||||
FfiStatus,
|
||||
} from "../../matrix-rtc-sdk";
|
||||
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
|
||||
import { testScope } from "../../utils/test";
|
||||
import { CallParticipation } from "./CallParticipation";
|
||||
import { joinParamsFromConfig, participationConfig } from "./joinParams";
|
||||
import { ELEMENT_CALL_SLOT_EVENT_TYPE, ELEMENT_CALL_SLOT_ID } from "./slot";
|
||||
import { publishOnLivekit } from "./transportIntent";
|
||||
|
||||
const enabled = env.MATRIX_RTC_BACKEND === "1";
|
||||
const HOMESERVER_URL = (
|
||||
env.HOMESERVER_URL ?? "https://synapse.m.localhost"
|
||||
).replace(/\/$/, "");
|
||||
/** The transport `backend/dev_homeserver.yaml` advertises. */
|
||||
const DEV_LIVEKIT_SERVICE_URL = "https://matrix-rtc.m.localhost/livekit/jwt";
|
||||
const DELAYED_EVENTS_PREFIX = "/_matrix/client/unstable/org.matrix.msc4140";
|
||||
|
||||
const session = {
|
||||
delayed_leave: { delay_ms: 18_000 },
|
||||
delegated_delayed_leave: { delay_ms: 3_600_000 },
|
||||
network_error_retry_ms: 1000,
|
||||
wait_for_key_rotation_ms: 50,
|
||||
};
|
||||
|
||||
interface TestUser {
|
||||
name: string;
|
||||
client: MatrixClient;
|
||||
room: Room;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
rtcDriver: JsSdkRtcMatrixDriver;
|
||||
clientDriver: JsSdkElementCallMatrixClientDriver;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
interface DelayedEvent {
|
||||
delay_id: string;
|
||||
room_id: string;
|
||||
type: string;
|
||||
state_key?: string;
|
||||
delay: number;
|
||||
running_since: number;
|
||||
content: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const log = (who: string, line: string): void =>
|
||||
// Progress of an opt-in integration run, meant to be read.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[backend ${who}] ${line}`);
|
||||
|
||||
/**
|
||||
* Register a throwaway user, boot rust crypto, sync, and be in the room.
|
||||
* `legacyMembers` gives every member the power to send the MSC3401 member
|
||||
* *state* event, as Element Call's own rooms do (`state_default: 0`); a
|
||||
* plain room keeps the default 50, which also keeps Bob from opening a slot.
|
||||
*/
|
||||
async function createUser(
|
||||
name: string,
|
||||
roomId?: string,
|
||||
{ encrypted = true, legacyMembers = false } = {},
|
||||
): Promise<TestUser> {
|
||||
const localpart = `ec-${name.toLowerCase()}-${Date.now().toString(16)}${Math.floor(
|
||||
Math.random() * 0xffff,
|
||||
).toString(16)}`;
|
||||
const register = async (): Promise<Response> =>
|
||||
fetch(`${HOMESERVER_URL}/_matrix/client/v3/register`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: localpart,
|
||||
password: `test-${localpart}`,
|
||||
auth: { type: "m.login.dummy" },
|
||||
}),
|
||||
});
|
||||
let response = await register();
|
||||
// Synapse rate-limits registrations; the second case of this file runs
|
||||
// straight into that.
|
||||
while (response.status === 429) {
|
||||
const { retry_after_ms: retryAfterMs = 1000 } = (await response.json()) as {
|
||||
retry_after_ms?: number;
|
||||
};
|
||||
await new Promise((resolve) => setTimeout(resolve, retryAfterMs + 100));
|
||||
response = await register();
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`registration failed: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
const {
|
||||
user_id: userId,
|
||||
device_id: deviceId,
|
||||
access_token: accessToken,
|
||||
} = (await response.json()) as {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
access_token: string;
|
||||
};
|
||||
const client = createClient({
|
||||
baseUrl: HOMESERVER_URL,
|
||||
accessToken,
|
||||
userId,
|
||||
deviceId,
|
||||
logger: logger.getChild(`[${name}]`),
|
||||
});
|
||||
// In memory on purpose: every run is a fresh device.
|
||||
await client.initRustCrypto({ useIndexedDB: false });
|
||||
await client.setDisplayName(name);
|
||||
void client.startClient();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.once(ClientEvent.Sync, (state) =>
|
||||
state === SyncState.Prepared
|
||||
? resolve()
|
||||
: reject(new Error(`sync failed: ${state}`)),
|
||||
);
|
||||
});
|
||||
|
||||
if (roomId === undefined) {
|
||||
const created = await client.createRoom({
|
||||
preset: Preset.PublicChat,
|
||||
name: `Element Call backend check ${new Date().toISOString()}`,
|
||||
power_level_content_override: legacyMembers
|
||||
? { events: { "org.matrix.msc3401.call.member": 0 } }
|
||||
: undefined,
|
||||
initial_state: encrypted
|
||||
? [
|
||||
{
|
||||
type: "m.room.encryption",
|
||||
state_key: "",
|
||||
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
roomId = created.room_id;
|
||||
} else {
|
||||
await client.joinRoom(roomId);
|
||||
}
|
||||
await waitFor(
|
||||
`${name}'s room to appear in sync`,
|
||||
() => client.getRoom(roomId) !== null,
|
||||
10_000,
|
||||
);
|
||||
const room = client.getRoom(roomId)!;
|
||||
log(name, `ready as ${userId} (${deviceId}) in ${roomId}`);
|
||||
|
||||
const rtcDriver = new JsSdkRtcMatrixDriver(client, room);
|
||||
const clientDriver = new JsSdkElementCallMatrixClientDriver(client, room);
|
||||
return {
|
||||
name,
|
||||
client,
|
||||
room,
|
||||
userId,
|
||||
deviceId,
|
||||
rtcDriver,
|
||||
clientDriver,
|
||||
stop: () => {
|
||||
rtcDriver.detach();
|
||||
client.stopClient();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function participate(
|
||||
user: TestUser,
|
||||
mode: MatrixRTCMode,
|
||||
manageMediaKeys: boolean,
|
||||
): CallParticipation {
|
||||
return new CallParticipation(
|
||||
testScope(),
|
||||
user.rtcDriver,
|
||||
user.room.roomId,
|
||||
user.userId,
|
||||
user.deviceId,
|
||||
{
|
||||
config: participationConfig({ mode, manageMediaKeys, session }),
|
||||
logger: logger.getChild(`[${user.name}]`),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function listDelayedEvents(
|
||||
client: MatrixClient,
|
||||
): Promise<DelayedEvent[]> {
|
||||
const response = await client.http.authedRequest<{
|
||||
delayed_events: DelayedEvent[];
|
||||
}>(Method.Get, "/delayed_events", undefined, undefined, {
|
||||
prefix: DELAYED_EVENTS_PREFIX,
|
||||
});
|
||||
return response.delayed_events;
|
||||
}
|
||||
|
||||
async function fetchRawEvent(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
eventId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return (await client.fetchRoomEvent(roomId, eventId)) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
function connected(
|
||||
participation: CallParticipation,
|
||||
): InstanceType<typeof FfiStatus.Connected>["inner"] {
|
||||
const status = participation.status$.value;
|
||||
if (!FfiStatus.Connected.instanceOf(status))
|
||||
throw new Error(`Expected Connected, got ${status.tag}`);
|
||||
return status.inner;
|
||||
}
|
||||
|
||||
describe.skipIf(!enabled)("CallParticipation against the dev backend", () => {
|
||||
beforeAll(async () => {
|
||||
// The two clients' crypto debug output would drown everything else.
|
||||
(logger as unknown as { setLevel(level: string): void }).setLevel("warn");
|
||||
await initMatrixRtcSdkForTests();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
mode: MatrixRTCMode.Matrix_2_0,
|
||||
memberEventType: "org.matrix.msc4143.rtc.member",
|
||||
sticky: true,
|
||||
},
|
||||
{
|
||||
mode: MatrixRTCMode.Compatibility,
|
||||
memberEventType: "org.matrix.msc3401.call.member",
|
||||
sticky: false,
|
||||
},
|
||||
])(
|
||||
"two users call each other in $mode mode",
|
||||
async ({ mode, memberEventType, sticky }) => {
|
||||
const alice = await createUser("Alice", undefined, {
|
||||
legacyMembers: !sticky,
|
||||
});
|
||||
const bob = await createUser("Bob", alice.room.roomId);
|
||||
const roomId = alice.room.roomId;
|
||||
const a = participate(alice, mode, true);
|
||||
const b = participate(bob, mode, true);
|
||||
const joinParams = joinParamsFromConfig({
|
||||
session,
|
||||
delegateDelayedLeave: true,
|
||||
});
|
||||
const dump = (): void => {
|
||||
log("alice", `snapshot: ${a.debugSnapshot()}`);
|
||||
log("bob", `snapshot: ${b.debugSnapshot()}`);
|
||||
};
|
||||
try {
|
||||
// --- connectivity, before anything else ------------------------------
|
||||
expect(alice.rtcDriver.isHomeserverConnected()).toBe(true);
|
||||
|
||||
// --- the slot ----------------------------------------------------------
|
||||
// A fresh room has no slot. Alice created it, so she may open one; Bob
|
||||
// (power level 0, state_default 50) may not.
|
||||
await waitFor("the seed", () => a.session$.value.seeded, 15_000);
|
||||
expect(a.session$.value.slotOpen).not.toBe(true);
|
||||
expect(
|
||||
alice.room.currentState.getStateEvents(ELEMENT_CALL_SLOT_EVENT_TYPE),
|
||||
).toEqual([]);
|
||||
const aliceRoom = alice.clientDriver.getRoomInfo();
|
||||
const bobRoom = bob.clientDriver.getRoomInfo();
|
||||
expect(aliceRoom.encrypted).toBe(true);
|
||||
expect(aliceRoom.canOpenSlot).toBe(true);
|
||||
expect(bobRoom.canOpenSlot).toBe(false);
|
||||
|
||||
// --- Alice joins: opens the slot, discovers the transport, publishes --
|
||||
await a.join(publishOnLivekit(), joinParams, {
|
||||
encrypted: aliceRoom.encrypted,
|
||||
canOpen: aliceRoom.canOpenSlot,
|
||||
});
|
||||
const aliceStatus = connected(a);
|
||||
log("alice", `joined: keepAlive=${aliceStatus.keepAlive.tag}`);
|
||||
|
||||
const slot = alice.room.currentState.getStateEvents(
|
||||
ELEMENT_CALL_SLOT_EVENT_TYPE,
|
||||
ELEMENT_CALL_SLOT_ID,
|
||||
);
|
||||
if (sticky) {
|
||||
expect(slot?.getContent()).toMatchObject({
|
||||
status: "open",
|
||||
application: { type: "m.call" },
|
||||
encryption: { type: "m.per_member" },
|
||||
});
|
||||
await waitFor(
|
||||
"bob to see the slot",
|
||||
() => b.session$.value.slotOpen === true,
|
||||
15_000,
|
||||
);
|
||||
} else {
|
||||
// The pre-slot generation: nothing opened, nothing to wait for.
|
||||
expect(slot).toBeNull();
|
||||
}
|
||||
|
||||
// The transport came from the homeserver's /rtc/transports.
|
||||
const [connection] = a.connections$.value;
|
||||
expect(connection.connection.serviceUrl).toBe(DEV_LIVEKIT_SERVICE_URL);
|
||||
expect(connection.connection.jwtToken.split(".")).toHaveLength(3);
|
||||
expect(connection.connection.wsUrl).toMatch(/^wss:\/\//);
|
||||
|
||||
// --- the member event on the wire -------------------------------------
|
||||
await waitFor(
|
||||
"alice to see her own membership",
|
||||
() => a.ownMembership$.value !== null,
|
||||
15_000,
|
||||
);
|
||||
const ownEventId = a.ownMembership$.value!.member.eventId;
|
||||
expect(ownEventId).toBeDefined();
|
||||
const raw = await fetchRawEvent(alice.client, roomId, ownEventId!);
|
||||
log("alice", `own member event: ${JSON.stringify(raw)}`);
|
||||
if (sticky) {
|
||||
// A sticky event is a timeline event: in an encrypted room matrix-js-sdk
|
||||
// Megolm-encrypts it like any other (its own MatrixRTC code decrypts
|
||||
// them on the way in, as our driver does). The sticky marker is in
|
||||
// the clear.
|
||||
expect(raw.type).toBe("m.room.encrypted");
|
||||
expect(raw.state_key).toBeUndefined();
|
||||
expect(raw).toHaveProperty("msc4354_sticky");
|
||||
const decrypted = [...alice.room._unstable_getStickyEvents()].find(
|
||||
(e) => e.getId() === ownEventId,
|
||||
);
|
||||
expect(decrypted?.getType()).toBe(memberEventType);
|
||||
} else {
|
||||
expect(raw.type).toBe(memberEventType);
|
||||
expect(raw.state_key).toBe(
|
||||
`_${alice.userId}_${alice.deviceId}_m.call`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- delayed events and their delegation -------------------------------
|
||||
const delayed = await listDelayedEvents(alice.client);
|
||||
log("alice", `delayed events: ${JSON.stringify(delayed)}`);
|
||||
expect(delayed).toHaveLength(1);
|
||||
expect(delayed[0].room_id).toBe(roomId);
|
||||
expect(delayed[0].type).toBe(memberEventType);
|
||||
const keepAlive = aliceStatus.keepAlive;
|
||||
if (sticky) {
|
||||
// The dev Synapse proxies `rtc/livekit/*` to lk-jwt-service
|
||||
// (MSC4512), so the homeserver route takes the long leave over.
|
||||
expect(FfiKeepAlive.Delegated.instanceOf(keepAlive)).toBe(true);
|
||||
expect(
|
||||
(keepAlive as InstanceType<typeof FfiKeepAlive.Delegated>).inner
|
||||
.via,
|
||||
).toBe(FfiDelegationRoute.Homeserver);
|
||||
expect(delayed[0].delay).toBe(
|
||||
session.delegated_delayed_leave.delay_ms,
|
||||
);
|
||||
} else {
|
||||
// MSC4195 is not spoken for the pre-slot generation: our own leave.
|
||||
expect(FfiKeepAlive.Armed.instanceOf(keepAlive)).toBe(true);
|
||||
expect(delayed[0].delay).toBe(session.delayed_leave.delay_ms);
|
||||
}
|
||||
|
||||
// --- Bob joins: roster, profiles, one connection with two members -----
|
||||
await b.join(publishOnLivekit(), joinParams, {
|
||||
encrypted: bobRoom.encrypted,
|
||||
canOpen: bobRoom.canOpenSlot,
|
||||
});
|
||||
connected(b);
|
||||
await waitFor(
|
||||
"alice to see bob",
|
||||
() =>
|
||||
a.memberships$.value.value.some(
|
||||
(m) => m.member.userId === bob.userId,
|
||||
),
|
||||
20_000,
|
||||
);
|
||||
await waitFor(
|
||||
"bob to see alice",
|
||||
() =>
|
||||
b.memberships$.value.value.some(
|
||||
(m) => m.member.userId === alice.userId,
|
||||
),
|
||||
20_000,
|
||||
);
|
||||
const bobSeenByAlice = a.memberships$.value.value.find(
|
||||
(m) => m.member.userId === bob.userId,
|
||||
)!;
|
||||
expect(bobSeenByAlice.member.displayName).toBe("Bob");
|
||||
expect(bobSeenByAlice.member.deviceId).toBe(bob.deviceId);
|
||||
expect(bobSeenByAlice.connections).toEqual([DEV_LIVEKIT_SERVICE_URL]);
|
||||
await waitFor(
|
||||
"two members on alice's connection",
|
||||
() => a.connections$.value[0]?.members.length === 2,
|
||||
20_000,
|
||||
);
|
||||
|
||||
// --- media keys, Olm-encrypted to-device both ways ---------------------
|
||||
await waitFor(
|
||||
"bob to hold alice's key",
|
||||
() =>
|
||||
b.keyMap$.value.some((k) => k.memberId === a.ownMemberId$.value),
|
||||
30_000,
|
||||
);
|
||||
await waitFor(
|
||||
"alice to hold bob's key",
|
||||
() =>
|
||||
a.keyMap$.value.some((k) => k.memberId === b.ownMemberId$.value),
|
||||
30_000,
|
||||
);
|
||||
await waitFor(
|
||||
"alice to know bob holds her key",
|
||||
() =>
|
||||
a.memberships$.value.value.find(
|
||||
(m) => m.member.userId === bob.userId,
|
||||
)?.mediaKey?.holdsOurKey === true,
|
||||
30_000,
|
||||
);
|
||||
const bobKeyState = a.memberships$.value.value.find(
|
||||
(m) => m.member.userId === bob.userId,
|
||||
)!.mediaKey!;
|
||||
log("alice", `bob's key state: ${JSON.stringify(bobKeyState)}`);
|
||||
expect(bobKeyState.haveTheirKey).toBe(true);
|
||||
expect(bobKeyState.rejection).toBeUndefined();
|
||||
// MSC4153 verdict travels with the key (C10): nobody here is
|
||||
// cross-signed, so the answer is "no", not "unknown".
|
||||
expect(bobKeyState.senderCrossSigned).toBe(false);
|
||||
|
||||
// --- Alice leaves: delayed events cancelled, Bob sees her go -----------
|
||||
await a.leave("m.user_hangup");
|
||||
expect(FfiStatus.Disconnected.instanceOf(a.status$.value)).toBe(true);
|
||||
expect(await listDelayedEvents(alice.client)).toEqual([]);
|
||||
await waitFor(
|
||||
"bob to see alice gone",
|
||||
() =>
|
||||
!b.memberships$.value.value.some(
|
||||
(m) => m.member.userId === alice.userId,
|
||||
),
|
||||
20_000,
|
||||
);
|
||||
|
||||
// --- losing the homeserver is a critical impairment (C12) -------------
|
||||
// Bob's network goes away: every request fails and the long-poll in
|
||||
// flight is cut, so matrix-js-sdk's sync loop leaves `Syncing`.
|
||||
const { opts } = bob.client.http;
|
||||
opts.fetchFn = async () => {
|
||||
return Promise.reject(new TypeError("network down"));
|
||||
};
|
||||
bob.client.http.abort();
|
||||
const unreachable = (): boolean =>
|
||||
connected(b).impairments.some((i) =>
|
||||
FfiImpairment.HomeserverUnreachable.instanceOf(i),
|
||||
);
|
||||
await waitFor(
|
||||
"bob's participation to notice the homeserver is gone",
|
||||
unreachable,
|
||||
15_000,
|
||||
);
|
||||
// ...and comes back: the sync loop recovers and the impairment clears.
|
||||
delete opts.fetchFn;
|
||||
await waitFor(
|
||||
"bob's participation to see the homeserver again",
|
||||
() => !unreachable(),
|
||||
30_000,
|
||||
);
|
||||
await b.leave();
|
||||
expect(FfiStatus.Disconnected.instanceOf(b.status$.value)).toBe(true);
|
||||
} catch (e) {
|
||||
dump();
|
||||
throw e;
|
||||
} finally {
|
||||
// Leave before the clients stop: a leave needs the homeserver.
|
||||
await a.leave();
|
||||
await b.leave();
|
||||
alice.stop();
|
||||
bob.stop();
|
||||
}
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
FfiDisconnectCause,
|
||||
FfiElementCallCompat,
|
||||
FfiEventOrigin,
|
||||
FfiStatus,
|
||||
type FfiMediaKey,
|
||||
} from "../../matrix-rtc-sdk";
|
||||
@@ -86,6 +87,65 @@ describe("CallParticipation", () => {
|
||||
await initMatrixRtcSdkForTests();
|
||||
});
|
||||
|
||||
it("in compatibility mode joins with a legacy state event and no slot", async () => {
|
||||
// a room that never had a slot, as every pre-slot room is
|
||||
const driver = new MockRtcMatrixDriver();
|
||||
const callParticipation = new CallParticipation(
|
||||
testScope(),
|
||||
driver,
|
||||
driver.roomId,
|
||||
driver.userId,
|
||||
driver.deviceId,
|
||||
{
|
||||
config: participationConfig({
|
||||
mode: MatrixRTCMode.Compatibility,
|
||||
manageMediaKeys: false,
|
||||
session,
|
||||
}),
|
||||
},
|
||||
);
|
||||
// no power to open a slot, and none is needed
|
||||
await callParticipation.join(receiveOnly(), joinParams, {
|
||||
encrypted: false,
|
||||
canOpen: false,
|
||||
});
|
||||
expect(
|
||||
FfiStatus.Connected.instanceOf(callParticipation.status$.value),
|
||||
).toBe(true);
|
||||
const stateEvents = driver.calls("stateEvent");
|
||||
expect(stateEvents.map((c) => c.eventType)).toEqual([
|
||||
"org.matrix.msc3401.call.member",
|
||||
]);
|
||||
expect(stateEvents[0].stateKey).toBe(
|
||||
`_${driver.userId}_${driver.deviceId}_m.call`,
|
||||
);
|
||||
// our own legacy membership echoes back into the roster
|
||||
await waitFor(
|
||||
"own membership",
|
||||
() => callParticipation.ownMembership$.value !== null,
|
||||
);
|
||||
expect(callParticipation.ownMemberId$.value).toBe(
|
||||
`${driver.userId}:${driver.deviceId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("session$ follows the seed and the slot without a join", async () => {
|
||||
const driver = new MockRtcMatrixDriver();
|
||||
const callParticipation = create(driver);
|
||||
expect(callParticipation.session$.value.seeded).toBe(false);
|
||||
await waitFor("the seed", () => callParticipation.session$.value.seeded);
|
||||
expect(callParticipation.session$.value.slotOpen).not.toBe(true);
|
||||
// somebody else starts the call
|
||||
driver.emitRoomEvent(
|
||||
slotEvent({ status: "open" }),
|
||||
new FfiEventOrigin.Cleartext(),
|
||||
);
|
||||
await waitFor(
|
||||
"the slot to open",
|
||||
() => callParticipation.session$.value.slotOpen === true,
|
||||
);
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -355,7 +415,7 @@ describe("CallParticipation", () => {
|
||||
FfiElementCallCompat.StateEvents,
|
||||
);
|
||||
expect(compatForMode(MatrixRTCMode.Matrix_2_0)).toBe(
|
||||
FfiElementCallCompat.StickyEvents,
|
||||
FfiElementCallCompat.Off,
|
||||
);
|
||||
expect(joinParams).toEqual({
|
||||
applicationType: "m.call",
|
||||
@@ -365,6 +425,7 @@ describe("CallParticipation", () => {
|
||||
keepAliveTimeoutMs: 15_000n,
|
||||
degradedLifetimeMs: undefined,
|
||||
delegateDelayedLeave: false,
|
||||
delegatedDelayMs: 3_600_000n,
|
||||
});
|
||||
expect(
|
||||
joinParamsFromConfig({
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
type FfiSessionSnapshot,
|
||||
type FfiToDeviceDelivery,
|
||||
type FfiToDeviceRecipient,
|
||||
type FfiHomeserverDelegationRequest,
|
||||
type FfiTransportDelegationRequest,
|
||||
type FfiTransportIntent,
|
||||
type ConnectivitySinkLike,
|
||||
type RoomEventSinkLike,
|
||||
@@ -41,7 +43,12 @@ import {
|
||||
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 {
|
||||
ELEMENT_CALL_APPLICATION,
|
||||
ELEMENT_CALL_SLOT_ID,
|
||||
LEGACY_SLOT_ID,
|
||||
slotIdForCompat,
|
||||
} from "./slot";
|
||||
import { LIVEKIT_TRANSPORT_TYPE } from "./transportIntent";
|
||||
|
||||
/**
|
||||
@@ -61,7 +68,10 @@ export interface SlotPolicy {
|
||||
const SLOT_WAIT_MS = 15_000;
|
||||
|
||||
export interface CallParticipationOptions {
|
||||
/** One manager per `(room, slot)`; Element Call has one slot per room. */
|
||||
/**
|
||||
* One manager per `(room, slot)`; Element Call has one slot per room.
|
||||
* Defaults to the slot for the config's dialect ({@link slotIdForCompat}).
|
||||
*/
|
||||
slotId?: string;
|
||||
config: FfiParticipationConfig;
|
||||
/**
|
||||
@@ -90,6 +100,7 @@ export class CallParticipation {
|
||||
private readonly matrixDriver: FfiMatrixDriver;
|
||||
private readonly manager: FfiParticipationManager;
|
||||
private ended = false;
|
||||
private readonly slotId: string;
|
||||
|
||||
private readonly membershipsSubject$: BehaviorSubject<FfiMembership[]>;
|
||||
private readonly connectionsSubject$: BehaviorSubject<
|
||||
@@ -145,9 +156,10 @@ export class CallParticipation {
|
||||
this.logger,
|
||||
);
|
||||
this.matrixDriver = new FfiMatrixDriver(rtcDriver);
|
||||
this.slotId = options.slotId ?? slotIdForCompat(options.config.compat);
|
||||
this.manager = new FfiParticipationManager(
|
||||
roomId,
|
||||
options.slotId ?? ELEMENT_CALL_SLOT_ID,
|
||||
this.slotId,
|
||||
userId,
|
||||
deviceId,
|
||||
this.matrixDriver,
|
||||
@@ -176,6 +188,13 @@ export class CallParticipation {
|
||||
this.refreshOwnIdentity();
|
||||
},
|
||||
});
|
||||
// The room's view of the session moves without any membership or status
|
||||
// of ours changing: the seed completing, somebody opening the slot.
|
||||
this.manager.setSessionListener({
|
||||
onSessionChange: (session) => {
|
||||
if (!this.ended) this.sessionSubject$.next(session);
|
||||
},
|
||||
});
|
||||
this.manager.setConnectionsListener({
|
||||
onConnectionsChange: (connections) => {
|
||||
if (!this.ended) this.connectionsSubject$.next(connections);
|
||||
@@ -248,6 +267,8 @@ export class CallParticipation {
|
||||
}
|
||||
|
||||
private async ensureOpenSlot(slot: SlotPolicy): Promise<void> {
|
||||
// The pre-slot generation has no slot to open or check.
|
||||
if (this.slotId === LEGACY_SLOT_ID) return;
|
||||
// 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(
|
||||
@@ -291,6 +312,17 @@ export class CallParticipation {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the call intent (`m.call.intent`) of our membership while
|
||||
* joined; the crate re-publishes it (C11). Rejects with the crate's
|
||||
* `NotJoined` error otherwise, which a caller that merely mirrors the
|
||||
* camera state can ignore.
|
||||
*/
|
||||
public async updateApplication(intent: string | undefined): Promise<void> {
|
||||
if (this.ended) throw new Error("The participation has ended");
|
||||
await this.manager.updateApplication(intent);
|
||||
}
|
||||
|
||||
/** The crate's diagnostics dump, for rageshakes. Not a UI contract. */
|
||||
public debugSnapshot(): string {
|
||||
return this.ended ? "{}" : this.manager.debugSnapshot();
|
||||
@@ -434,22 +466,15 @@ class TransportFallbackDriver implements RtcMatrixDriver {
|
||||
): Promise<void> {
|
||||
return this.inner.cancelDelayedEvent(roomId, delayId);
|
||||
}
|
||||
public async delegateLivekitDelayedLeave(
|
||||
roomId: string,
|
||||
slotId: string,
|
||||
memberJson: string,
|
||||
delayId: string,
|
||||
livekitServiceUrl: string | undefined,
|
||||
delayMs: bigint,
|
||||
public async delegateDelayedLeaveViaHomeserver(
|
||||
request: FfiHomeserverDelegationRequest,
|
||||
): Promise<void> {
|
||||
return this.inner.delegateLivekitDelayedLeave(
|
||||
roomId,
|
||||
slotId,
|
||||
memberJson,
|
||||
delayId,
|
||||
livekitServiceUrl,
|
||||
delayMs,
|
||||
);
|
||||
return this.inner.delegateDelayedLeaveViaHomeserver(request);
|
||||
}
|
||||
public async delegateDelayedLeaveViaTransport(
|
||||
request: FfiTransportDelegationRequest,
|
||||
): Promise<void> {
|
||||
return this.inner.delegateDelayedLeaveViaTransport(request);
|
||||
}
|
||||
public async sendToDevice(
|
||||
recipients: FfiToDeviceRecipient[],
|
||||
|
||||
@@ -22,17 +22,18 @@ const MAX_STICKY_DURATION_MS = 60 * 60 * 1000;
|
||||
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.
|
||||
* Which dialect the crate speaks for a given mode. `compatibility` is
|
||||
* MSC3401 state events for the clients that predate sticky events;
|
||||
* `matrix_2_0` is spec MSC4143 — sticky member events, slots (which Element
|
||||
* Call opens) and the spec key message, the same wire format Element X's
|
||||
* matrix-rtc crates speak.
|
||||
*/
|
||||
export function compatForMode(mode: MatrixRTCMode): FfiElementCallCompat {
|
||||
switch (mode) {
|
||||
case MatrixRTCMode.Compatibility:
|
||||
return FfiElementCallCompat.StateEvents;
|
||||
case MatrixRTCMode.Matrix_2_0:
|
||||
return FfiElementCallCompat.StickyEvents;
|
||||
return FfiElementCallCompat.Off;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,20 +74,24 @@ 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;
|
||||
/**
|
||||
* Hand the delayed leave to the SFU (MSC4195). The crate tries the
|
||||
* homeserver, then the authorisation service, then keeps restarting the
|
||||
* leave itself, so there is no reason not to ask. Tests turn it off.
|
||||
*/
|
||||
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.
|
||||
* `delegated_delayed_leave.restart_ms`) have no effect any more.
|
||||
*/
|
||||
export function joinParamsFromConfig({
|
||||
session,
|
||||
callIntent,
|
||||
delegateDelayedLeave,
|
||||
delegateDelayedLeave = true,
|
||||
}: JoinParamsInputs): FfiJoinParams {
|
||||
return {
|
||||
applicationType: ELEMENT_CALL_APPLICATION,
|
||||
@@ -100,5 +105,6 @@ export function joinParamsFromConfig({
|
||||
keepAliveTimeoutMs: BigInt(session.delayed_leave.delay_ms),
|
||||
degradedLifetimeMs: undefined,
|
||||
delegateDelayedLeave,
|
||||
delegatedDelayMs: BigInt(session.delegated_delayed_leave.delay_ms),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FfiElementCallCompat } from "../../matrix-rtc-sdk";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -17,3 +19,17 @@ export const ELEMENT_CALL_APPLICATION = "m.call";
|
||||
* it, which is what `RoomInfo.canOpenSlot` answers.
|
||||
*/
|
||||
export const ELEMENT_CALL_SLOT_EVENT_TYPE = "org.matrix.msc4143.rtc.slot";
|
||||
/**
|
||||
* The crate's slot id for the pre-slot generation (MSC3401 state events,
|
||||
* `MatrixRTCMode.Compatibility`): that generation has no `m.rtc.slot`, so the
|
||||
* session is projected from the legacy state events alone and nobody opens
|
||||
* or checks a slot. The crate requires this id under `StateEvents` compat.
|
||||
*/
|
||||
export const LEGACY_SLOT_ID = "";
|
||||
|
||||
/** The slot a participation lives in for a given wire dialect. */
|
||||
export function slotIdForCompat(compat: FfiElementCallCompat): string {
|
||||
return compat === FfiElementCallCompat.StateEvents
|
||||
? LEGACY_SLOT_ID
|
||||
: ELEMENT_CALL_SLOT_ID;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
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 { BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
FfiDeviceAttribution,
|
||||
FfiMembershipState,
|
||||
type FfiConnectionWithMembers,
|
||||
type FfiMediaKey,
|
||||
type FfiMember,
|
||||
type FfiMembership,
|
||||
} from "../matrix-rtc-sdk";
|
||||
import { Epoch } from "../state/ObservableScope";
|
||||
|
||||
/**
|
||||
* Hand-made values of the crate's records, for the modules that consume a
|
||||
* `CallParticipation`'s behaviors without needing the crate itself.
|
||||
*/
|
||||
|
||||
export function fakeMember(overrides: Partial<FfiMember> = {}): FfiMember {
|
||||
const memberId = overrides.memberId ?? "m-peer";
|
||||
return {
|
||||
memberId,
|
||||
userId: "@peer:example.org",
|
||||
deviceId: "PEERDEV",
|
||||
deviceAttribution: FfiDeviceAttribution.Verified,
|
||||
eventId: `$${memberId}`,
|
||||
publishedTransports: [],
|
||||
canSubscribe: ["livekit"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeMembership(
|
||||
overrides: Partial<Omit<FfiMembership, "member">> & {
|
||||
member?: Partial<FfiMember>;
|
||||
} = {},
|
||||
): FfiMembership {
|
||||
const { member, ...rest } = overrides;
|
||||
return {
|
||||
member: fakeMember(member),
|
||||
state: FfiMembershipState.Joined,
|
||||
connections: [],
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeConnection(
|
||||
overrides: Partial<FfiConnectionWithMembers["connection"]> & {
|
||||
members?: FfiMember[];
|
||||
} = {},
|
||||
): FfiConnectionWithMembers {
|
||||
const { members = [], ...connection } = overrides;
|
||||
const serviceUrl = connection.serviceUrl ?? "https://lk.example.org";
|
||||
return {
|
||||
connection: {
|
||||
serviceUrl,
|
||||
wsUrl: serviceUrl.replace("https", "wss"),
|
||||
jwtToken: "jwt",
|
||||
...connection,
|
||||
},
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
export function fakeMediaKey(
|
||||
overrides: Partial<FfiMediaKey> = {},
|
||||
): FfiMediaKey {
|
||||
return {
|
||||
memberId: "m-peer",
|
||||
key: new Uint8Array(32).fill(7).buffer,
|
||||
index: 0,
|
||||
creationTsMs: 0n,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The behaviors of a `CallParticipation`, as subjects a test drives by hand.
|
||||
* Modules take structural slices of the participation, so this stands in for
|
||||
* it wherever the crate is not what is under test.
|
||||
*/
|
||||
export class FakeParticipation {
|
||||
public readonly memberships$ = new BehaviorSubject(
|
||||
new Epoch<FfiMembership[]>([], 0),
|
||||
);
|
||||
public readonly connections$ = new BehaviorSubject<
|
||||
FfiConnectionWithMembers[]
|
||||
>([]);
|
||||
public readonly keyMap$ = new BehaviorSubject<FfiMediaKey[]>([]);
|
||||
public readonly ownMemberId$ = new BehaviorSubject<string | null>(null);
|
||||
public readonly ownTransportIdentity$ = new BehaviorSubject<string | null>(
|
||||
null,
|
||||
);
|
||||
public readonly ownMembership$ = new BehaviorSubject<FfiMembership | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/** Replace the roster, advancing the epoch as the real thing does. */
|
||||
public setMemberships(memberships: FfiMembership[]): void {
|
||||
this.memberships$.next(
|
||||
new Epoch(memberships, this.memberships$.value.epoch + 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import {
|
||||
type CallViewModel,
|
||||
createCallViewModel$,
|
||||
createJsClientCallViewModel$,
|
||||
type CallViewModelOptions,
|
||||
} from "../state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
@@ -159,7 +159,7 @@ export function getBasicCallViewModelEnvironment(
|
||||
const scope = testScope();
|
||||
const muteStates = mockMuteStates();
|
||||
const mediaDevices = mediaDevicesOverride ?? mockMediaDevices({});
|
||||
const vm = createCallViewModel$(
|
||||
const vm = createJsClientCallViewModel$(
|
||||
scope,
|
||||
rtcSession.asMockedSession(),
|
||||
matrixRoom,
|
||||
|
||||
@@ -58,6 +58,7 @@ export default defineConfig((configEnv) =>
|
||||
"src/utils/test-viewmodel.ts",
|
||||
"src/utils/test-fixtures.ts",
|
||||
"src/utils/test-matrix-rtc.ts",
|
||||
"src/utils/test-participation.ts",
|
||||
"src/matrix-rtc-sdk/generated/**",
|
||||
"playwright/**",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user