Move ElementCall Component to the rust based call view model

This commit is contained in:
Timo K.
2026-09-16 16:19:25 +02:00
parent 8506c098e9
commit e4049e3d69
65 changed files with 3676 additions and 1126 deletions
+11 -2
View File
@@ -44,9 +44,17 @@ jobs:
flags: unittests
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
name: Run end-to-end tests (${{ matrix.call_implementation }})
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Both MatrixRTC implementations, until matrix-rtc becomes the
# default and matrix-js-sdk goes (oxidation plan §5.15). The
# matrix-rtc run does not block until it has passed once.
call_implementation: [matrix-js-sdk, matrix-rtc]
continue-on-error: ${{ matrix.call_implementation == 'matrix-rtc' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -80,11 +88,12 @@ jobs:
- name: Run Playwright tests
env:
USE_DOCKER: 1
CALL_VIEW_MODEL_IMPLEMENTATION: ${{ matrix.call_implementation }}
run: pnpm exec playwright test
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
name: html-report
name: html-report-${{ matrix.call_implementation }}
path: playwright-report
if-no-files-found: error
retention-days: 4
+5
View File
@@ -10,6 +10,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import EN from "../locales/en/app.json";
import { Config } from "../src/config/Config";
import { initReactI18next } from "react-i18next";
import { i18n } from "../src/utils/i18n";
import "../src/index.css";
@@ -35,6 +36,10 @@ i18n
})
.catch((e) => logger.warn("Failed to init i18n for stories", e));
// Stories run without a config.json; the defaults stand in, as they do for
// a component host that passes none.
Config.initWith({});
const preview: Preview = {
parameters: {
layout: "centered",
+41 -44
View File
@@ -43,7 +43,7 @@ import {
useRef,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { type MatrixClient, type Room } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { I18nextProvider } from "react-i18next";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -56,7 +56,6 @@ import LanguageDetector from "i18next-browser-languagedetector";
import EN from "../locales/en/app.json";
import { CallView } from "../src/room/CallView";
import { ErrorPage } from "../src/FullScreenView";
import { ClientProvider } from "../src/ClientContext";
import { HostBridgeProvider } from "../src/HostBridge";
import { RootElementProvider, useRootElement } from "../src/RootElementContext";
import {
@@ -80,8 +79,15 @@ 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 { useJsSdkDrivers } from "../src/driver/jsSdk/useJsSdkDrivers";
import {
initMatrixRtcSdk,
type MatrixRtcWasmSource,
} from "../src/matrix-rtc-sdk";
import {
type MatrixDrivers,
MatrixDriverProvider,
} from "../src/driver/MatrixDriverContext";
import styles from "./ElementCall.module.css";
import {
type ElementCallHandle,
@@ -231,10 +237,24 @@ export type ElementCallClientBasedProps = Omit<
*
* Await this once, before rendering {@link ElementCall}.
*/
export interface InitializeElementCallOptions {
/**
* Where to load the MatrixRTC SDK's wasm from, for a host that serves it
* from somewhere other than next to this bundle. Left out, the bundled
* copy is loaded when the first call needs it.
*/
matrixRtcWasm?: MatrixRtcWasmSource;
}
export async function initializeElementCall(
config: ConfigOptions = {},
{ matrixRtcWasm }: InitializeElementCallOptions = {},
): Promise<void> {
const polyfills: Promise<unknown>[] = [];
// The call runs on the Rust MatrixRTC crate; its wasm is loaded up front
// when the host says where from, and lazily otherwise.
if (matrixRtcWasm !== undefined)
polyfills.push(initMatrixRtcSdk(matrixRtcWasm));
if (shouldPolyfillSegmenter())
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
if (shouldPolyfillDurationFormat())
@@ -343,22 +363,11 @@ export const ElementCall: FC<ElementCallProps> = ({
};
}, [controlledAudioDevices, callIntent]);
// 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(
() => client.matrixRTC.getRoomSession(room),
[client, room],
// The call runs on the Rust MatrixRTC crate over these two drivers; no
// matrix-js-sdk client is involved.
const drivers = useMemo(
(): MatrixDrivers => ({ rtcDriver, clientDriver }),
[rtcDriver, clientDriver],
);
// Everything the call needs is in hand once these exist, and the first
@@ -366,8 +375,7 @@ export const ElementCall: FC<ElementCallProps> = ({
// is told that Element Call has loaded, as the widget tells its client once
// its own initialisation is over. Once per mount, however often the pieces
// are later swapped out.
const ready =
container !== null && rtcSession !== null && mediaDevices !== null;
const ready = container !== null && mediaDevices !== null;
const announcedLoaded = useRef(false);
useEffect(() => {
if (!ready || announcedLoaded.current) return;
@@ -393,12 +401,10 @@ export const ElementCall: FC<ElementCallProps> = ({
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MatrixDriverProvider value={drivers}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
@@ -406,7 +412,7 @@ export const ElementCall: FC<ElementCallProps> = ({
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</MatrixDriverProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
@@ -425,28 +431,19 @@ export const ElementCallClientBased: FC<ElementCallClientBasedProps> = ({
...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) {
if (room === null) {
logger.error(
`Element Call was asked to call in ${roomId}, which its host's client does not know about`,
);
return null;
}
return <ClientBasedCall client={client} room={room} {...props} />;
};
/** {@link ElementCallClientBased} once the room is known: builds the drivers. */
const ClientBasedCall: FC<
Omit<ElementCallClientBasedProps, "roomId"> & { room: Room }
> = ({ client, room, ...props }): ReactNode => {
const drivers = useJsSdkDrivers(client, room);
return <ElementCall {...props} {...drivers} />;
};
+161 -7
View File
@@ -175,6 +175,7 @@ membership, roster, encryption, impairments} | Leaving`.
| 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. |
| C14 | The crate logs through the `log` facade (104 call sites) but installed no logger, so in wasm every line was dropped: seeding, joins, delegation fallbacks and key rotation left no trace in a rageshake. | **Done (2026-09-16).** `LogSink` foreign trait (`log(level, target, message)`), `FfiLogLevel`, and `set_log_sink(sink, max_level)`, which installs a `log::Log` forwarding to the sink (a host that already installed a Rust logger keeps it). Element Call installs `matrixRtcLogSink` under `[matrix-rtc]` on the js-sdk root logger from `initMatrixRtcSdk()` at debug; the web-test-app installs a console sink (warn in tests). |
No crate work is deferred: `update_application` is C11.
@@ -512,8 +513,11 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
`assetsInlineLimit`; `?url&no-inline` emits a file. App builds use `?url`;
the component build uses `?url&no-inline` plus an `exports` entry for
`./dist/assets/*`, and `initializeElementCall(config, { matrixRtcWasm })`
lets a host point elsewhere. vitest reads the file from disk; wasm boot is
**lazy** (only suites that need it call `initMatrixRtcSdk()`), never in
lets a host point elsewhere. Wasm boot is **lazy** everywhere: in the app
`useCallParticipation` awaits `initMatrixRtcSdk()` before constructing
the participation (so the js-sdk path never fetches it, §5.15), not the
`Initializer`; vitest reads the file from disk and only suites that need
it call `initMatrixRtcSdk()`, never in
`src/vitest.setup.ts`; Storybook boots it in `.storybook/preview.tsx`
`beforeAll`. Suites using `vi.useFakeTimers` never share a file with
real-wasm tests (pumps sleep on `setTimeout`).
@@ -557,6 +561,24 @@ RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
redactions), as they are with the js-sdk MatrixRTC code today. The driver
decrypts them on the way in; the sticky marker (`msc4354_sticky`) stays in
the clear. To cross-check against Element X before relying on it.
15. **The two view models stay switchable for one release.** A developer
setting, `callViewModelImplementation` (`"matrix-js-sdk"` |
`"matrix-rtc"`, `src/settings/settings.ts`, next to `matrixRTCMode`),
picks between `createJsClientCallViewModel$` and the driver-based
`createCallViewModel$` in the same build. Like `matrixRTCMode` it is
shown in the Developer Settings tab, sampled when the call is joined
(switching mid-call needs a rejoin), and a deployment can pin it through
`config.json` (`call_view_model_implementation`), which overrides the
user's choice and greys the control out. The default is
`"matrix-js-sdk"` when the setting lands (S4a) and flips to
`"matrix-rtc"` once the S5 gate is green; S6 removes the setting together
with the js-sdk path. The point is that a broken call can be compared
against the old path in the same session, and that Playwright can run
both paths from one build. Everything the js-sdk path needs (the client,
the `MatrixRTCSession`, `ReactionsReader` over the session) therefore
stays reachable from `CallView` until S6, and the `CallParticipation` is
created only when the crate path is selected, so neither path pays for
the other.
---
@@ -663,19 +685,116 @@ participation, clientDriver, …)` sits next to it; both build a
LiveKit: join → connection → own tile → peer → leave; transport-missing →
`fatalError$`), plus one file per module.
### S4 — React tree, two slices ☐
### S4 — React tree, two slices ☑ (2026-09-16)
- **S4a-0, the switch (§5.15) ☑:** `CallViewModelImplementation` enum,
`callViewModelImplementation` setting (`src/settings/settings.ts`),
`call_view_model_implementation` pin (`ConfigOptions.ts`, validated in
`Config.ts`), the radio group in `DeveloperSettingsTab.tsx`, the
implementation logged at join and sent as `call_view_model_implementation`
in rageshakes, `effectiveCallViewModelImplementation()` in
`src/state/rtc/implementation.ts`. `CallView` samples it once per mount;
with `matrix-rtc` and no drivers it warns and lets matrix-js-sdk carry the
call. Still open from this item: the Playwright helper and the second CI
run.
- **S4a done so far:** `MatrixDriverProvider` / `useMatrixDrivers()`
(`src/driver/MatrixDriverContext.tsx`), provided by the component from its
props and by `RoomPage` through `useJsSdkDrivers(client, room)`;
`useCallParticipation(drivers, config)` owned by `CallView`, created only
when the crate path is selected; `ActiveCall` takes `participation` and
builds either view model; the lobby's member count, the big-call auto-mute
and the error boundary's "were we joined" read from whichever side carries
the call; `window.matrixRtc = { participation }` next to `window.rtcSession`.
- (The original S4a-0 description follows for reference.) `callViewModelImplementation` setting and
`config.json` pin (`ConfigOptions.ts`, validated at load like
`matrix_rtc_mode`); a radio group in `DeveloperSettingsTab.tsx` beside the
MatrixRTC mode; `ActiveCall` reads the sampled value and calls either
factory — for `"matrix-rtc"` it takes the drivers from the
`MatrixDriverProvider` and the `CallParticipation` from `CallView`, for
`"matrix-js-sdk"` it keeps `rtcSession`/`matrixRoom` as today. The chosen
implementation is logged at join and added to the rageshake fields so a
report says which path it came from. Playwright gets a helper that sets
the setting (local storage) before the call so every call spec can run
under both values; CI runs the suite twice until the default flips.
Lands first in S4a, before anything else in the tree moves, so that every
later S4 change is verifiable against the old path.
- **S4a** views/hooks/settings on the driver: `CallView.tsx` (owns
`CallParticipation`), `InCallView.tsx`, `LobbyView.tsx`, `CallEndedView.tsx`,
`CallParticipation` when the crate path is selected), `InCallView.tsx`, `LobbyView.tsx`, `CallEndedView.tsx`,
`VideoPreview.tsx`, `useRoomInfo()` (replaces `useRoomName/Avatar/JoinRule/State`),
`InviteModal.tsx`, `Avatar.tsx`, `useOwnProfile.ts`, `ProfileSettingsTab.tsx`,
`SettingsModal.tsx`, `DeveloperSettingsTab.tsx`, `submit-rageshake.ts`,
`DisconnectedBanner.tsx`, `analytics/PosthogEvents.ts`, `controls.ts`, and a
first `CallView.stories.tsx` (lobby, in call, ended) driven by
`MockMatrixDriver`.
- **S4b** reactions and notifications: `useReactionsSender.tsx`,
`ReactionsReader` keyed by `memberId`, `CallNotificationLifecycle` sending
through the driver; tests incl. a membership re-send mid-call.
- **S4b ☑ (2026-09-16):** `ReactionsSenderProvider` takes `ownIdentifier`,
`ownMembershipEventId` and a `ReactionsTimeline` (`jsSdkReactionsTimeline`
over a client, or the client driver); `ParticipationReactionsReader`
(`src/reactions/`) reads hands and reactions from the participation and
the timeline driver, keyed by the member's media id
(`memberMediaId`, `src/state/rtc/mediaId.ts`), and re-resolves a hand on a
re-sent membership instead of dropping it blindly. Notifications went
through the driver in S3d. Tests: `ParticipationReactionsReader.test.ts`,
`useCallParticipation.test.tsx`, three `CallView.test.tsx` cases for the
switch.
- **S4a views on the drivers (2026-09-16):** `CallView` now requires the
drivers (`useMatrixDrivers()`; both hosts provide them) and reads the
room through `useRoomInfo()` (`src/room/useRoomInfo.ts`; `useRoomAvatar`,
`useJoinRule`, `useRoomState` deleted, `useRoomName` stays for `RoomPage`),
our own profile through `useOwnProfile()` (`src/profile/useOwnProfile.ts`)
and the encryption system through `useEncryptionSystemFor(roomId,
roomInfo.encrypted)` (`useRoomEncryptionSystem` keeps the client for the
home page). `MatrixInfo` is built from those. `InviteModal` takes
`roomId`/`roomName`/`e2eeSystem`; `CallEndedView` lost its `client` prop;
`Avatar` resolves thumbnails through `clientDriver.thumbnailUrl` when
drivers are provided (host `downloadMedia` first, client fallback for the
shell); rageshakes carry the driver's `getDiagnostics()` as `driver_*`
fields and the crate's `matrix_rtc_snapshot`.
- **The component is client-free (2026-09-16):** `CallView`'s `client` and
`rtcSession` are optional; without them the crate carries the call
whatever the setting says (`useMatrixRtc = setting || no session`), and
every js-sdk-only piece (the `MembershipManagerError` listener, the room
sanity check, `ReactionsReader`, `mediaKeyStatisticsOf`) is skipped.
`ActiveCall`/`InCallView` take `roomId` instead of `matrixRoom`, and
`client`/`rtcSession` optionally. `ProfileSettingsTab` edits through the
client driver's optional `setDisplayName`/`setAvatar(file | null)` and
shows read-only fields without them; `DeveloperSettingsTab` reads the
sticky probe from `getCapabilities()`, the crypto version from
`getDiagnostics()` and validates a custom LiveKit URL through
`rtcDriver.getLivekitToken` when there is no client; the rageshake request
event goes through the client driver's timeline; reactions are supported
unless a client state forbids them. `ElementCall` renders no
`ClientProvider` and no shim — it hands its two drivers down and nothing
below asks for a client; `initializeElementCall(config, { matrixRtcWasm })`
can preload the wasm. `PosthogEvents.eventCallEnded.track` takes
`MediaKeyStatistics` (from the session on the js-sdk path, zero on the
crate path until the crate counts). `useTypedEventEmitter` and the js-sdk
client driver's public `client`/`room` are gone. **Consequence:** the
component and its dev harness (`ElementCallClientBased`) now run every
call on the crate.
- **Banner on the driver (2026-09-16):** `useHomeserverConnected(drivers,
graceMs)` (`src/driver/`) follows `rtcDriver.isHomeserverConnected()` /
`subscribeConnectivity` and reports a lapse only after
`sync_disconnect_grace_period_ms`, since the driver reports every sync
hiccup where the client state waited for a `ConnectionError`;
`DisconnectedBanner` uses it whenever drivers are provided and falls back
to the client state for the shell. The mock RTC driver keeps several
connectivity sinks (the crate's and the UI's).
- **S4 closed (2026-09-16):** `CallView.stories.tsx` (Lobby with a peer,
NoTransport as the error path, Ended) over the mock drivers, no client;
`.storybook/preview.tsx` initialises the config; the Storybook vitest
project passes. The Playwright switch is `CALL_VIEW_MODEL_IMPLEMENTATION`
read by `playwright.config.ts` into `use.storageState` (the developer
setting in local storage for every context), and the CI Playwright job is
a matrix over both implementations (`matrix-rtc` non-blocking until it
has passed once). `CallParticipation.mediaKeyStatistics()` counts sent
and received keys and their age from the crate's key changes for the
ended-call event. Found on the way: `CallView` rendered `ActiveCall`
before the participation existed (wasm loads on first use) — it now waits;
and `onLeft` had gained the member count as a dependency, which rebuilt
the view model on every roster change on both paths — read through
`useLatest` now. `create-call.spec.ts` passes in Chromium on both
implementations with two view model creations each (StrictMode): the
crate path's first full call in a browser.
### S5 — hosts ☐ (component props done 2026-09-15)
@@ -708,6 +827,9 @@ participation, clientDriver, …)` sits next to it; both build a
`src/IndexedDBWorker.ts`, `src/room/KnockLobbyView.tsx`, `src/settings/rageshake.ts`)
banning `matrix-js-sdk` except `matrix-js-sdk/lib/logger`.
- `ServiceInterruptionsViewModel` fed from `status$.impairments`.
- `callViewModelImplementation` setting, its `config.json` pin and the
Developer Settings control removed with `createJsClientCallViewModel$`
(§5.15); Playwright runs the suite once again.
- `docs/agents/architecture.md`, `docs/matrix_rtc_modes.md` updated;
`src/@types/matrix-js-sdk.d.ts` removed if nothing merges into js-sdk types.
@@ -807,3 +929,35 @@ props moved to the two drivers with `ElementCallClientBased` on top. Gates:
participation (S4b), the `?url&no-inline` wasm asset for the component build
(the wasm is loaded lazily and inlined into the component bundle today), and
the S6 deletions.
**S4 (2026-09-16):** the implementation switch (§5.15) is in with its
config pin, developer control and rageshake field; `CallView` owns a
`CallParticipation` when the crate path is selected and `ActiveCall` builds
the matching view model; the reactions reader and sender have participation
and driver counterparts. Both paths run in the same build. Gates: `pnpm
lint`, `format:check`, `i18n:check`, `test:unit` (815), `build:component`
green. Not verified: a real call on the crate path through the UI (the
`pnpm backend` check covers the view model's Matrix side; the browser run is
the S5 gate).
**Logging (2026-09-16):** C14 — the crate's log lines reach the host through
a `LogSink`; Element Call routes them to its rageshake logger under
`[matrix-rtc]`, the web-test-app to the console. Verified by a unit test that
sees the crate's "session created" line through the sink.
**S4 views (2026-09-16):** room info, own profile, encryption system,
avatars, the invite and ended views and the rageshake fields read the
drivers; `CallView` requires drivers. Three room hooks deleted. Gates:
`pnpm lint`, `format:check`, `test:unit` (819), `build:component` green.
**Client-free component (2026-09-16):** `CallView` and everything under it
run without a matrix-js-sdk client on the crate path; the component passes
only its drivers. Gates: `pnpm lint`, `format:check`, `test:unit` (819),
`build:component` green. Not yet done: a browser run of the component on
the crate path (the dev harness now is that run).
**S4 done (2026-09-16):** stories, Playwright switch and CI matrix, key
statistics. `create-call.spec.ts` green on `matrix-js-sdk` and `matrix-rtc`
in Chromium against the dev server; the rest of the suite under
`matrix-rtc` is the S5 gate. Gates: `pnpm lint`, `format:check`,
`test:unit` (822), `test:storybook` (28), `build:component` green.
+12
View File
@@ -58,6 +58,18 @@
},
"developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"callViewModelImplementation": {
"forced": "Your deployment pins the call implementation.",
"matrix_js_sdk": {
"description": "The MatrixRTC session of matrix-js-sdk carries the call, as before",
"label": "matrix-js-sdk"
},
"matrix_rtc": {
"description": "The Rust matrix-rtc crate carries the call through the host's drivers",
"label": "matrix-rtc (Rust)"
},
"title": "Call implementation"
},
"crypto_version": "Crypto version: {{version}}",
"custom_livekit_url": {
"current_url": "Currently set to: ",
+27
View File
@@ -19,6 +19,32 @@ const baseURL = process.env.USE_DOCKER
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Which MatrixRTC implementation carries the calls under test, `matrix-js-sdk`
* or `matrix-rtc` (see `CallViewModelImplementation` in
* src/config/ConfigOptions.ts). Unset, Element Call's default applies. Set,
* every browser context starts with the developer setting in local storage,
* so the whole suite runs on that implementation.
*/
const callViewModelImplementation = process.env.CALL_VIEW_MODEL_IMPLEMENTATION;
const storageState =
callViewModelImplementation === undefined
? undefined
: {
cookies: [],
origins: [
{
origin: baseURL,
localStorage: [
{
name: "matrix-setting-call-view-model-implementation",
value: JSON.stringify(callViewModelImplementation),
},
],
},
],
};
// Needed by the synapse admin API called in fixtures
process.env.NODE_EXTRA_CA_CERTS = join(
__dirname,
@@ -44,6 +70,7 @@ export default defineConfig({
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL,
storageState,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
+28 -9
View File
@@ -16,6 +16,7 @@ import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { useClientState } from "./ClientContext";
import { useOptionalMatrixDrivers } from "./driver/MatrixDriverContext";
import { useHostBridge } from "./HostBridge";
export enum Size {
@@ -76,6 +77,9 @@ export const Avatar: FC<Props> = ({
}) => {
const clientState = useClientState();
const hostBridge = useHostBridge();
// Under a call the host's client driver resolves media; the shell outside
// a call still has only the client.
const drivers = useOptionalMatrixDrivers();
const sizePx = useMemo(
() =>
@@ -95,16 +99,30 @@ export const Avatar: FC<Props> = ({
return;
}
let blob: Promise<Blob>;
let url: Promise<string | null>;
if (hostBridge.downloadMedia) {
blob = hostBridge.downloadMedia(src);
url = hostBridge
.downloadMedia(src)
.then((blob) => URL.createObjectURL(blob));
} else if (drivers !== null) {
const px = Math.floor(sizePx * window.devicePixelRatio);
url = drivers.clientDriver.thumbnailUrl(
src,
px,
px,
sizePx <= 96 ? "crop" : "scale",
);
} else if (
clientState?.state === "valid" &&
clientState.authenticated?.client &&
sizePx
) {
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
url = getAvatarFromServer(
clientState.authenticated.client,
src,
sizePx,
).then((blob) => URL.createObjectURL(blob));
} else {
setAvatarUrl(undefined);
return;
@@ -112,13 +130,14 @@ export const Avatar: FC<Props> = ({
let objectUrl: string | undefined;
let stale = false;
blob
.then((blob) => {
if (stale) {
url
.then((resolved) => {
if (stale || resolved === null) {
return;
}
objectUrl = URL.createObjectURL(blob);
setAvatarUrl(objectUrl);
// Only what we created (or the driver created for us) is ours to revoke.
if (resolved.startsWith("blob:")) objectUrl = resolved;
setAvatarUrl(resolved);
})
.catch((ex) => {
if (stale) {
@@ -133,7 +152,7 @@ export const Avatar: FC<Props> = ({
URL.revokeObjectURL(objectUrl);
}
};
}, [clientState, hostBridge, src, sizePx]);
}, [clientState, hostBridge, drivers, src, sizePx]);
return (
<CompoundAvatar
+9 -1
View File
@@ -11,6 +11,8 @@ import { useTranslation } from "react-i18next";
import styles from "./DisconnectedBanner.module.css";
import { type ValidClientState, useClientState } from "./ClientContext";
import { useOptionalMatrixDrivers } from "./driver/MatrixDriverContext";
import { useHomeserverConnected } from "./driver/useHomeserverConnected";
interface Props extends HTMLAttributes<HTMLElement> {
children?: ReactNode;
@@ -23,10 +25,16 @@ export const DisconnectedBanner: FC<Props> = ({
...rest
}) => {
const { t } = useTranslation();
// Under a call the host's RTC driver says whether the homeserver is
// reachable; the shell outside a call only has the client's sync state.
const drivers = useOptionalMatrixDrivers();
const homeserverConnected = useHomeserverConnected(drivers);
const clientState = useClientState();
let shouldShowBanner = false;
if (clientState?.state === "valid") {
if (drivers !== null) {
shouldShowBanner = !homeserverConnected;
} else if (clientState?.state === "valid") {
const validClientState = clientState as ValidClientState;
shouldShowBanner = validClientState.disconnected;
}
+8 -7
View File
@@ -19,6 +19,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { PosthogAnalytics } from "./PosthogAnalytics";
import {
mediaKeyStatisticsOf,
CallEndedTracker,
CallReconnectingTracker,
type CallReconnectingReason,
@@ -67,7 +68,7 @@ describe("CallEnded", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.track("test-call-id", 2, false, mockSession);
tracker.track("test-call-id", 2, false, mediaKeyStatisticsOf(mockSession));
expect(warnSpy).toHaveBeenCalledWith(
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
@@ -81,7 +82,7 @@ describe("CallEnded", () => {
tracker.cacheStartCall(new Date(Date.now() - 60000));
tracker.cacheParticipantCountChanged(5);
tracker.track("test-call-id", 3, true, mockSession);
tracker.track("test-call-id", 3, true, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
{
@@ -111,7 +112,7 @@ describe("CallEnded", () => {
tracker.cacheParticipantCountChanged(3);
tracker.cacheParticipantCountChanged(7);
tracker.cacheParticipantCountChanged(2);
tracker.track("test-call-id", 1, false, mockSession);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -128,7 +129,7 @@ describe("CallEnded", () => {
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -146,7 +147,7 @@ describe("CallEnded", () => {
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -161,7 +162,7 @@ describe("CallEnded", () => {
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.anything(),
@@ -178,7 +179,7 @@ describe("CallEnded", () => {
tracker.cacheReconnecting("sync");
tracker.cacheReconnecting("livekit");
tracker.cacheReconnecting("membership");
tracker.track("test-call-id", 1, false, mockSession);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
+32 -10
View File
@@ -15,6 +15,34 @@ import {
RegistrationType,
} from "./PosthogAnalytics";
/** How many media keys went out and came in over a call, for the ended event. */
export interface MediaKeyStatistics {
sent: number;
received: number;
/** Sum of the ages of the received keys, in ms; averaged on send. */
receivedTotalAge: number;
}
/** No key statistics (the Rust crate does not count them yet). */
export const NO_MEDIA_KEY_STATISTICS: MediaKeyStatistics = {
sent: 0,
received: 0,
receivedTotalAge: 0,
};
/** {@link MediaKeyStatistics} from a matrix-js-sdk session. */
export function mediaKeyStatisticsOf(
rtcSession: MatrixRTCSession,
): MediaKeyStatistics {
const { counters, totals } = rtcSession.statistics;
return {
sent: counters.roomEventEncryptionKeysSent,
received: counters.roomEventEncryptionKeysReceived,
// Only meaningful with received keys; a mocked session may not carry it.
receivedTotalAge: totals?.roomEventEncryptionKeysReceivedTotalAge ?? 0,
};
}
interface CallEnded extends IPosthogEvent {
eventName: "CallEnded";
// the callId posthog key is essentially a Matrix roomId
@@ -80,7 +108,7 @@ export class CallEndedTracker {
callId: string,
callParticipantsNow: number,
sendInstantly: boolean,
rtcSession: MatrixRTCSession,
keys: MediaKeyStatistics,
): void {
if (this.cache.startTime) {
PosthogAnalytics.instance.trackEvent<CallEnded>(
@@ -90,16 +118,10 @@ export class CallEndedTracker {
callParticipantsMax: this.cache.maxParticipantsCount,
callParticipantsOnLeave: callParticipantsNow,
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
roomEventEncryptionKeysSent:
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
roomEventEncryptionKeysReceived:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
roomEventEncryptionKeysSent: keys.sent,
roomEventEncryptionKeysReceived: keys.received,
roomEventEncryptionKeysReceivedAverageAge:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
? rtcSession.statistics.totals
.roomEventEncryptionKeysReceivedTotalAge /
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
: 0,
keys.received > 0 ? keys.receivedTotalAge / keys.received : 0,
callReconnectingCount: this.cache.reconnectingCount,
callReconnectingCountSync: this.cache.reconnectingCountByReason.sync,
callReconnectingCountMembership:
+10 -2
View File
@@ -17,7 +17,10 @@ import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { type MockRTCSession } from "../utils/test";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import {
jsSdkReactionsTimeline,
ReactionsSenderProvider,
} from "../reactions/useReactionsSender";
import { initializeWidget } from "../widget";
initializeWidget();
vi.mock("livekit-client/e2ee-worker?worker");
@@ -35,7 +38,12 @@ function TestComponent({
<TooltipProvider>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession.asMockedSession()}
ownIdentifier={localIdent}
ownMembershipEventId={localRtcMember.eventId}
timeline={jsSdkReactionsTimeline(
rtcSession.room.client,
rtcSession.room.roomId,
)}
>
<ReactionToggleButton
reactionData={{
+23 -1
View File
@@ -9,7 +9,11 @@ import { describe, expect, it, vi, afterEach } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { Config, validateConfig } from "./Config";
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
import {
CallViewModelImplementation,
DEFAULT_CONFIG,
MatrixRTCMode,
} from "./ConfigOptions";
describe("validateConfig", () => {
afterEach(() => {
@@ -101,3 +105,21 @@ describe("Config.initWith", () => {
expect(Config.get().ssla).toBe("https://second.invalid/ssla");
});
});
describe("validateConfig call_view_model_implementation", () => {
it.each(Object.values(CallViewModelImplementation))(
"keeps a valid call_view_model_implementation value (%s)",
(value) => {
const result = validateConfig({ call_view_model_implementation: value });
expect(result.call_view_model_implementation).toBe(value);
},
);
it("drops an unknown call_view_model_implementation value", () => {
const result = validateConfig({
call_view_model_implementation:
"yes-please" as unknown as CallViewModelImplementation,
});
expect(result.call_view_model_implementation).toBeUndefined();
});
});
+14 -1
View File
@@ -15,11 +15,14 @@ import {
type ResolvedConfigOptions,
} from "./ConfigOptions";
import { isFailure } from "../utils/fetch";
import { MatrixRTCMode } from "./ConfigOptions";
import { CallViewModelImplementation, MatrixRTCMode } from "./ConfigOptions";
const VALID_MATRIX_RTC_MODES: ReadonlySet<string> = new Set(
Object.values(MatrixRTCMode),
);
const VALID_CALL_VIEW_MODEL_IMPLEMENTATIONS: ReadonlySet<string> = new Set(
Object.values(CallViewModelImplementation),
);
export class Config {
private static internalInstance: Config | undefined;
@@ -136,6 +139,16 @@ export function validateConfig(config: ConfigOptions): ConfigOptions {
);
delete config.matrix_rtc_mode;
}
const implementation = config.call_view_model_implementation;
if (
implementation !== undefined &&
!VALID_CALL_VIEW_MODEL_IMPLEMENTATIONS.has(implementation)
) {
logger.warn(
`Ignoring invalid call_view_model_implementation in config.json: ${String(implementation)}`,
);
delete config.call_view_model_implementation;
}
return config;
}
+20
View File
@@ -24,6 +24,18 @@ export enum MatrixRTCMode {
Matrix_2_0 = "matrix_2_0",
}
/**
* Which MatrixRTC implementation carries a call: matrix-js-sdk's
* `MatrixRTCSession`, or the Rust `matrix-rtc` crate through the host's
* drivers. Both are in the build while the crate path is being proven, so a
* broken call can be compared against the other path in the same session;
* the js-sdk path and this choice go away together.
*/
export enum CallViewModelImplementation {
MatrixJsSdk = "matrix-js-sdk",
MatrixRtc = "matrix-rtc",
}
export interface DelayedLeaveTimings {
/**
* The delay (in milliseconds) with which delayed leave events are sent.
@@ -202,6 +214,14 @@ export interface ConfigOptions {
*/
matrix_rtc_mode?: MatrixRTCMode;
/**
* Pins the {@link CallViewModelImplementation} for all clients on this
* deployment, overriding any per-user choice from the Developer Settings.
* If unset, the user's Developer Settings choice (or its default) wins.
* Temporary: goes away with the matrix-js-sdk implementation.
*/
call_view_model_implementation?: CallViewModelImplementation;
/**
* These are low level options that are used to configure the MatrixRTC session.
* Take care when changing these options.
+2 -1
View File
@@ -125,7 +125,8 @@ export interface ProfileDriver {
getOwnProfile(): OwnProfile;
subscribeOwnProfile(listener: (profile: OwnProfile) => void): Unsubscribe;
setDisplayName?(name: string): Promise<void>;
setAvatar?(file: Blob): Promise<void>;
/** Sets the avatar to `file`, or removes it with `null`. */
setAvatar?(file: Blob | null): Promise<void>;
}
export interface MediaDriver {
+41
View File
@@ -0,0 +1,41 @@
/*
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 { createContext, use } from "react";
import { type ElementCallMatrixClientDriver } from "./ElementCallMatrixClientDriver";
import { type RtcMatrixDriver } from "./RtcMatrixDriver";
/** The host's two drivers for the room a call is in. */
export interface MatrixDrivers {
rtcDriver: RtcMatrixDriver;
clientDriver: ElementCallMatrixClientDriver;
}
const MatrixDriverContext = createContext<MatrixDrivers | null>(null);
/**
* Makes the drivers available to the call tree. Every host that renders a
* `CallView` provides them: the component from its props, the standalone
* app and the widget from the matrix-js-sdk drivers over their client.
*/
export const MatrixDriverProvider = MatrixDriverContext.Provider;
/** The drivers, or null where no host provided any (a test rendering a leaf). */
export function useOptionalMatrixDrivers(): MatrixDrivers | null {
return use(MatrixDriverContext);
}
/** The drivers; throws where none were provided. */
export function useMatrixDrivers(): MatrixDrivers {
const drivers = useOptionalMatrixDrivers();
if (drivers === null)
throw new Error(
"No Matrix drivers were provided; wrap the call in a MatrixDriverProvider",
);
return drivers;
}
+4 -3
View File
@@ -145,7 +145,7 @@ export class MockRtcMatrixDriver implements RtcMatrixDriver {
private roomEventSink?: RoomEventSinkLike;
private toDeviceSink?: ToDeviceSinkLike;
private stateUpdateSink?: StateUpdateSinkLike;
private connectivitySink?: ConnectivitySinkLike;
private readonly connectivitySinks = new Set<ConnectivitySinkLike>();
private homeserverConnected = true;
private nextDelayId = 0;
@@ -403,7 +403,7 @@ export class MockRtcMatrixDriver implements RtcMatrixDriver {
}
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
this.connectivitySink = sink;
this.connectivitySinks.add(sink);
}
public isHomeserverConnected(): boolean {
@@ -413,7 +413,8 @@ export class MockRtcMatrixDriver implements RtcMatrixDriver {
/** The homeserver comes or goes, as a syncing client would report it. */
public setHomeserverConnected(connected: boolean): void {
this.homeserverConnected = connected;
this.connectivitySink?.emit(connected);
for (const sink of this.connectivitySinks)
if (!sink.emit(connected)) this.connectivitySinks.delete(sink);
}
/** Emit any room event — sticky or state; the crate dispatches on type. */
@@ -64,13 +64,8 @@ export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClie
private capabilities: Promise<DriverCapabilities> | null = null;
public constructor(
/**
* 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,
private readonly client: MatrixClient,
private readonly room: Room,
options: JsSdkElementCallMatrixClientDriverOptions = {},
) {
const userId = client.getUserId();
@@ -271,7 +266,11 @@ export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClie
await this.client.setDisplayName(name);
}
public async setAvatar(file: Blob): Promise<void> {
public async setAvatar(file: Blob | null): Promise<void> {
if (file === null) {
await this.client.setAvatarUrl("");
return;
}
const { content_uri: uri } = await this.client.uploadContent(file);
await this.client.setAvatarUrl(uri);
}
+37
View File
@@ -0,0 +1,37 @@
/*
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 MatrixClient, type Room } from "matrix-js-sdk";
import { useEffect, useMemo } from "react";
import { type MatrixDrivers } from "../MatrixDriverContext";
import { JsSdkElementCallMatrixClientDriver } from "./JsSdkElementCallMatrixClientDriver";
import { JsSdkRtcMatrixDriver } from "./JsSdkRtcMatrixDriver";
/**
* The two matrix-js-sdk drivers for a room, for as long as the client and
* room stay the same. The RTC driver hooks listeners on the client for the
* crate's sinks; they are let go of when the drivers are replaced or the
* caller unmounts.
*/
export function useJsSdkDrivers(
client: MatrixClient,
room: Room,
): MatrixDrivers {
const drivers = useMemo(
(): MatrixDrivers => ({
rtcDriver: new JsSdkRtcMatrixDriver(client, room),
clientDriver: new JsSdkElementCallMatrixClientDriver(client, room),
}),
[client, room],
);
useEffect(
() => (): void => (drivers.rtcDriver as JsSdkRtcMatrixDriver).detach(),
[drivers],
);
return drivers;
}
@@ -0,0 +1,63 @@
/*
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 { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type MatrixDrivers } from "./MatrixDriverContext";
import { MockElementCallMatrixClientDriver } from "./MockElementCallMatrixClientDriver";
import { MockRtcMatrixDriver } from "./MockRtcMatrixDriver";
import { useHomeserverConnected } from "./useHomeserverConnected";
describe("useHomeserverConnected", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("reports a lapse only after the grace period, and a return at once", () => {
const rtcDriver = new MockRtcMatrixDriver();
const drivers: MatrixDrivers = {
rtcDriver,
clientDriver: new MockElementCallMatrixClientDriver(),
};
const { result } = renderHook(() => useHomeserverConnected(drivers, 1000));
expect(result.current).toBe(true);
act(() => rtcDriver.setHomeserverConnected(false));
expect(result.current).toBe(true);
act(() => vi.advanceTimersByTime(999));
expect(result.current).toBe(true);
act(() => vi.advanceTimersByTime(1));
expect(result.current).toBe(false);
act(() => rtcDriver.setHomeserverConnected(true));
expect(result.current).toBe(true);
// A blip shorter than the grace period is never shown.
act(() => rtcDriver.setHomeserverConnected(false));
act(() => vi.advanceTimersByTime(500));
act(() => rtcDriver.setHomeserverConnected(true));
act(() => vi.advanceTimersByTime(1000));
expect(result.current).toBe(true);
});
it("starts from the driver's current answer", () => {
const rtcDriver = new MockRtcMatrixDriver();
rtcDriver.setHomeserverConnected(false);
const drivers: MatrixDrivers = {
rtcDriver,
clientDriver: new MockElementCallMatrixClientDriver(),
};
const { result } = renderHook(() => useHomeserverConnected(drivers, 1000));
act(() => vi.advanceTimersByTime(1000));
expect(result.current).toBe(false);
});
it("is connected without drivers", () => {
const { result } = renderHook(() => useHomeserverConnected(null, 1000));
expect(result.current).toBe(true);
});
});
+58
View File
@@ -0,0 +1,58 @@
/*
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 { useEffect, useState } from "react";
import { Config } from "../config/Config";
import { type MatrixDrivers } from "./MatrixDriverContext";
/**
* Whether the homeserver is reachable, from the RTC driver's connectivity
* (the same signal the crate turns into `HomeserverUnreachable`). The driver
* reports every lapse of the sync loop; the UI should not, so a lapse counts
* only once it has lasted `graceMs` (the deployment's
* `sync_disconnect_grace_period_ms` by default). Coming back counts at once.
*
* `null` drivers (a host that provided none) read as connected: there is
* nothing to report on.
*/
export function useHomeserverConnected(
drivers: MatrixDrivers | null,
graceMs: number = Config.get().sync_disconnect_grace_period_ms,
): boolean {
const [connected, setConnected] = useState(true);
useEffect(() => {
if (drivers === null) {
setConnected(true);
return;
}
const { rtcDriver } = drivers;
let live = true;
let lapse: ReturnType<typeof setTimeout> | null = null;
const report = (isConnected: boolean): void => {
if (lapse !== null) {
clearTimeout(lapse);
lapse = null;
}
if (isConnected) setConnected(true);
else lapse = setTimeout(() => setConnected(false), graceMs);
};
report(rtcDriver.isHomeserverConnected());
rtcDriver.subscribeConnectivity({
emit: (isConnected) => {
if (!live) return false;
report(isConnected);
return true;
},
});
return (): void => {
live = false;
if (lapse !== null) clearTimeout(lapse);
};
}, [drivers, graceMs]);
return connected;
}
+27 -8
View File
@@ -87,8 +87,16 @@ export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { client } = useClient();
/**
* The encryption system for a room whose `m.room.encryption` state the
* caller already knows: a shared secret from the URL or storage wins, else
* per-participant keys in an encrypted room, else nothing. The call tree
* reads `roomEncrypted` from the client driver's room info.
*/
export function useEncryptionSystemFor(
roomId: string,
roomEncrypted: boolean,
): EncryptionSystem {
const { roomId: paramsRoomId, password } = useUrlParams();
const [storedPassword] = useRoomSharedKey(
@@ -100,18 +108,29 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
);
const room = client?.getRoom(roomId);
const e2eeSystem = <EncryptionSystem>useMemo(() => {
if (!room) return { kind: E2eeType.NONE };
return <EncryptionSystem>useMemo(() => {
if (storedPassword)
return {
kind: E2eeType.SHARED_KEY,
secret: storedPassword,
};
if (room.hasEncryptionStateEvent()) {
if (roomEncrypted) {
return { kind: E2eeType.PER_PARTICIPANT };
}
return { kind: E2eeType.NONE };
}, [room, storedPassword]);
return e2eeSystem;
}, [roomEncrypted, storedPassword]);
}
/** {@link useEncryptionSystemFor} over the client's room; nothing for a room it does not know. */
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { client } = useClient();
const room = client?.getRoom(roomId);
const e2eeSystem = useEncryptionSystemFor(
roomId,
room?.hasEncryptionStateEvent() ?? false,
);
return useMemo(
() => (room ? e2eeSystem : { kind: E2eeType.NONE }),
[room, e2eeSystem],
);
}
+2 -2
View File
@@ -1,2 +1,2 @@
matrix-rtc (MatrixSdkArchitectureDraft) 088a598-dirty
built 2026-09-15T18:22:09Z by scripts/sync-matrix-rtc-sdk.sh
matrix-rtc (MatrixSdkArchitectureDraft) 61140bc-dirty
built 2026-09-16T12:10:06Z by scripts/sync-matrix-rtc-sdk.sh
+8
View File
@@ -36,6 +36,14 @@ export type UniffiVTableCallbackInterfaceMatrixRtcKeyRejectedListener = {
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcKeyRejectedListener;
on_key_rejected: UniffiCallbackInterfaceMatrixRtcKeyRejectedListenerMethod0;
};
type UniffiCallbackInterfaceMatrixRtcLogSinkMethod0 = (uniffiHandle: bigint, level: Uint8Array, target: Uint8Array, message: Uint8Array) => UniffiResult<void>;
type UniffiCallbackInterfaceCloneMatrixRtcLogSink = (handle: bigint) => UniffiResult<void>;
type UniffiCallbackInterfaceFreeMatrixRtcLogSink = (handle: bigint) => void;
export type UniffiVTableCallbackInterfaceMatrixRtcLogSink = {
uniffi_free: UniffiCallbackInterfaceFreeMatrixRtcLogSink;
uniffi_clone: UniffiCallbackInterfaceCloneMatrixRtcLogSink;
log: UniffiCallbackInterfaceMatrixRtcLogSinkMethod0;
};
export type UniffiForeignFutureResultRustBuffer = {
return_value: Uint8Array;
call_status: UniffiRustCallStatus;
+250 -1
View File
@@ -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 UniffiVTableCallbackInterfaceMatrixRtcSessionListener, type UniffiVTableCallbackInterfaceMatrixRtcStatusListener,
import { type UniffiRustFutureContinuationCallback, type UniffiForeignFutureDroppedCallback, type UniffiForeignFutureDroppedCallbackStruct, type UniffiVTableCallbackInterfaceMatrixRtcConnectionsListener, type UniffiVTableCallbackInterfaceMatrixRtcKeyMapListener, type UniffiVTableCallbackInterfaceMatrixRtcKeyRejectedListener, type UniffiVTableCallbackInterfaceMatrixRtcLogSink, 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";
@@ -75,6 +75,23 @@ export function impairmentSeverity(impairment: FfiImpairment): FfiSeverity {
}
}
/**
* Routes the crate's log lines to `sink`, at `max_level` and above. Call
* once after the bindings are initialised; calling again replaces the sink
* and the level. The `log` facade accepts one logger per process, so if the
* host already installed a Rust logger of its own (a native host with
* `tracing`), that one keeps the lines and the sink stays silent.
*/
export function setLogSink(sink: LogSink, maxLevel: FfiLogLevel): void {uniffiCaller.rustCall(
/*caller:*/ (callStatus) => { nativeModule().ubrn_uniffi_matrix_rtc_fn_func_set_log_sink(
FfiConverterTypeLogSink.lower(sink, nativeModule().rustbuffer_alloc),
FfiConverterTypeFfiLogLevel.lower(maxLevel, nativeModule().rustbuffer_alloc),
callStatus);
},
/*liftString:*/ FfiConverterString.lift.bind(FfiConverterString),
);
}
const stringConverter = (() => {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
@@ -3788,6 +3805,46 @@ const FfiConverterTypeFfiKeepAlive = (() => {
return new FFIConverter();
})();
/**
* The `log` crate's levels, for a [`LogSink`].
*/
export enum FfiLogLevel {
Error,
Warn,
Info,
Debug,
Trace
}
const FfiConverterTypeFfiLogLevel = (() => {
type TypeName = FfiLogLevel;
class FFIConverter extends AbstractFfiConverterByteArray<TypeName> {
readFromCursor(c: Cursor): TypeName {
switch (c.readI32()) {
case 1: return FfiLogLevel.Error;
case 2: return FfiLogLevel.Warn;
case 3: return FfiLogLevel.Info;
case 4: return FfiLogLevel.Debug;
case 5: return FfiLogLevel.Trace;
default: throw new UniffiInternalError.UnexpectedEnumCase();
}
}
writeIntoCursor(value: TypeName, c: Cursor): void {
switch (value) {
case FfiLogLevel.Error: return c.writeI32(1);
case FfiLogLevel.Warn: return c.writeI32(2);
case FfiLogLevel.Info: return c.writeI32(3);
case FfiLogLevel.Debug: return c.writeI32(4);
case FfiLogLevel.Trace: return c.writeI32(5);
}
}
allocationSize(value: TypeName): number {
return 4;
}
}
return new FFIConverter();
})();
// Enum: FfiRosterPresence
export enum FfiRosterPresence_Tags {
@@ -7027,6 +7084,189 @@ const uniffiTypeFfiParticipationManagerObjectFactory: UniffiObjectFactory<FfiPar
}})();
const FfiConverterTypeFfiParticipationManager = new FfiConverterObject(uniffiTypeFfiParticipationManagerObjectFactory);
/**
* Where the crate's log lines go. Nothing is logged until a host installs
* one with [`set_log_sink`]: the crate speaks through the `log` facade and
* has no output of its own, so that its lines land in the host's log (and
* its rageshakes) rather than on a console the host does not read.
*
* `target` is the Rust module path (`matrix_rtc::own_membership::machine`),
* `message` the formatted line. Called synchronously from wherever the
* crate logs; keep it cheap and never call back into the crate from it.
*/
export interface LogSink {
log(level: FfiLogLevel, target: string, message: string): void;
}
/**
* Where the crate's log lines go. Nothing is logged until a host installs
* one with [`set_log_sink`]: the crate speaks through the `log` facade and
* has no output of its own, so that its lines land in the host's log (and
* its rageshakes) rather than on a console the host does not read.
*
* `target` is the Rust module path (`matrix_rtc::own_membership::machine`),
* `message` the formatted line. Called synchronously from wherever the
* crate logs; keep it cheap and never call back into the crate from it.
*/
export class LogSinkImpl extends UniffiAbstractObject implements LogSink {
readonly [uniffiTypeNameSymbol] = "LogSinkImpl";
readonly [destructorGuardSymbol]: UniffiGcObject;
readonly [pointerLiteralSymbol]: UniffiHandle;
// No primary constructor declared for this class.
private constructor(pointer: UniffiHandle) {
super();
this[pointerLiteralSymbol] = pointer;
this[destructorGuardSymbol] = uniffiTypeLogSinkImplObjectFactory.bless(pointer);
}
log(level: FfiLogLevel, target: string, message: string): void {uniffiCaller.rustCall(
/*caller:*/ (callStatus) => { nativeModule().ubrn_uniffi_matrix_rtc_fn_method_logsink_log(
uniffiTypeLogSinkImplObjectFactory.clonePointer(this),
FfiConverterTypeFfiLogLevel.lower(level, nativeModule().rustbuffer_alloc),
FfiConverterString.lower(target, nativeModule().rustbuffer_alloc),
FfiConverterString.lower(message, nativeModule().rustbuffer_alloc),
callStatus);
},
/*liftString:*/ FfiConverterString.lift.bind(FfiConverterString),
);
}
uniffiDestroy(): void {
const ptr = (this as any)[destructorGuardSymbol];
if (ptr !== undefined) {
const pointer = uniffiTypeLogSinkImplObjectFactory.pointer(this);
uniffiTypeLogSinkImplObjectFactory.freePointer(pointer);
uniffiTypeLogSinkImplObjectFactory.unbless(ptr);
delete (this as any)[destructorGuardSymbol];
}
}
static instanceOf(obj_: any): obj_ is LogSinkImpl {
return uniffiTypeLogSinkImplObjectFactory.isConcreteType(obj_);
}
}
const uniffiTypeLogSinkImplObjectFactory: UniffiObjectFactory<LogSink> = (() => {
/// <reference lib="es2021" />
const registry = typeof FinalizationRegistry !== 'undefined' ? new FinalizationRegistry<UniffiHandle>((heldValue: UniffiHandle) => {
uniffiTypeLogSinkImplObjectFactory.freePointer(heldValue);
}) : null;
return {
create(pointer: UniffiHandle): LogSink {
const instance = Object.create(LogSinkImpl.prototype);
instance[pointerLiteralSymbol] = pointer;
instance[destructorGuardSymbol] = this.bless(pointer);
instance[uniffiTypeNameSymbol] = "LogSinkImpl";
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_: LogSink): UniffiHandle {
if ((obj_ as any)[destructorGuardSymbol] === undefined) {
throw new UniffiInternalError.UnexpectedNullPointer();
}
return (obj_ as any)[pointerLiteralSymbol];
},
clonePointer(obj_: LogSink): UniffiHandle {
const pointer = this.pointer(obj_);
return uniffiCaller.rustCall(
/*caller:*/ (callStatus) => nativeModule().ubrn_uniffi_matrix_rtc_fn_clone_logsink(pointer, callStatus),
/*liftString:*/ FfiConverterString.lift
);
},
freePointer(pointer: UniffiHandle): void {
uniffiCaller.rustCall(
/*caller:*/ (callStatus) => nativeModule().ubrn_uniffi_matrix_rtc_fn_free_logsink(pointer, callStatus),
/*liftString:*/ FfiConverterString.lift
);
},
isConcreteType(obj_: any): obj_ is LogSink {
return obj_[destructorGuardSymbol] && obj_[uniffiTypeNameSymbol] === "LogSinkImpl";
},
}})();
const FfiConverterTypeLogSink = new FfiConverterObjectWithCallbacks(uniffiTypeLogSinkImplObjectFactory);
// Add a vtable for the callbacks that go in LogSink.
// Put the implementation in a struct so we don't pollute the top-level namespace
const uniffiCallbackInterfaceLogSink: { vtable: any; register: () => void; } = {
// Create the VTable using a series of closures.
// ts automatically converts these into C callback functions.
vtable: {
log: (
uniffiHandle: bigint,
level: Uint8Array,
target: Uint8Array,
message: Uint8Array,) => {
const uniffiMakeCall =
()
: void => {
const jsCallback = FfiConverterTypeLogSink.lift(uniffiHandle);
return jsCallback.log(
FfiConverterTypeFfiLogLevel.lift(level),
FfiConverterString.lift(target),
FfiConverterString.lift(message)
)
};
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.
FfiConverterTypeLogSink.drop(uniffiHandle);
},
uniffi_clone: (uniffiHandle: UniffiHandle): UniffiHandle => {
return FfiConverterTypeLogSink.clone(uniffiHandle);
}
},
register: () => {nativeModule().ubrn_uniffi_matrix_rtc_fn_init_callback_vtable_logsink(
uniffiCallbackInterfaceLogSink.vtable
);
},
};
/**
* One end of a driver event stream: Rust-exported objects handed to the
* foreign driver through the `subscribe_*` methods. The host calls `emit`
@@ -8963,6 +9203,9 @@ function uniffiEnsureInitialized() {
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_func_impairment_severity() !== 9139) {
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_func_impairment_severity");
}
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_func_set_log_sink() !== 7798) {
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_func_set_log_sink");
}
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_connectionslistener_on_connections_change() !== 24219) {
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_connectionslistener_on_connections_change");
}
@@ -9047,6 +9290,9 @@ function uniffiEnsureInitialized() {
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_keyrejectedlistener_on_key_rejected() !== 53044) {
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_keyrejectedlistener_on_key_rejected");
}
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_logsink_log() !== 15220) {
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_logsink_log");
}
if (nativeModule().ubrn_uniffi_matrix_rtc_checksum_method_matrixdrivercallback_send_sticky_event() !== 39725) {
throw new UniffiInternalError.ApiChecksumMismatch("uniffi_matrix_rtc_checksum_method_matrixdrivercallback_send_sticky_event");
}
@@ -9126,6 +9372,7 @@ function uniffiEnsureInitialized() {
uniffiCallbackInterfaceMembershipsListener.register();
uniffiCallbackInterfaceSessionListener.register();
uniffiCallbackInterfaceStatusListener.register();
uniffiCallbackInterfaceLogSink.register();
uniffiCallbackInterfaceMatrixDriverCallback.register();
}
@@ -9157,6 +9404,7 @@ export default Object.freeze({
FfiConverterTypeFfiKeyRejection,
FfiConverterTypeFfiLivekitToken,
FfiConverterTypeFfiLivekitTokenRequest,
FfiConverterTypeFfiLogLevel,
FfiConverterTypeFfiMatrixDriver,
FfiConverterTypeFfiMediaKey,
FfiConverterTypeFfiMediaKeyState,
@@ -9179,6 +9427,7 @@ export default Object.freeze({
FfiConverterTypeFfiTransportIntent,
FfiConverterTypeKeyMapListener,
FfiConverterTypeKeyRejectedListener,
FfiConverterTypeLogSink,
FfiConverterTypeMatrixDriverCallback,
FfiConverterTypeMembershipsListener,
FfiConverterTypeRoomEventSink,
File diff suppressed because it is too large Load Diff
Binary file not shown.
+61 -1
View File
@@ -5,10 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it, onTestFinished, vi } from "vitest";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
import { installMatrixRtcLogSink, matrixRtcLogSink } from "./logSink";
import {
FfiLogLevel,
setLogSink,
FfiElementCallCompat,
FfiMatrixDriver,
FfiParticipationManager,
@@ -80,6 +84,62 @@ describe("matrix-rtc-sdk", () => {
expect(manager.ownTransportIdentity()).toBeUndefined();
manager.uniffiDestroy();
});
it("routes the crate's log lines to the installed sink", () => {
const lines: { level: FfiLogLevel; target: string; message: string }[] = [];
setLogSink(
{
log: (level, target, message) => {
lines.push({ level, target, message });
},
},
FfiLogLevel.Debug,
);
// Put the default sink back for the other suites.
onTestFinished(() => installMatrixRtcLogSink());
const driver = new FfiMatrixDriver(new InertDriver());
const manager = new FfiParticipationManager(
ROOM_ID,
"m.call#ROOM",
"@me:example.org",
"MYDEV",
driver,
{
compat: FfiElementCallCompat.Off,
manageMediaKeys: false,
requireCrossSignedSender: false,
useKeyDelayMs: 1000n,
},
);
manager.uniffiDestroy();
const created = lines.find((l) => l.message.includes("session created"));
expect(created).toBeDefined();
expect(created?.level).toBe(FfiLogLevel.Info);
expect(created?.target).toMatch(/^matrix_rtc::/);
});
it("maps the crate's levels onto a matrix-js-sdk logger", () => {
const target = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
};
const sink = matrixRtcLogSink(target as unknown as Logger);
sink.log(FfiLogLevel.Error, "matrix_rtc::a", "boom");
sink.log(FfiLogLevel.Warn, "matrix_rtc::a", "hm");
sink.log(FfiLogLevel.Info, "matrix_rtc::a", "fyi");
sink.log(FfiLogLevel.Debug, "matrix_rtc::a", "dbg");
sink.log(FfiLogLevel.Trace, "matrix_rtc::a", "trc");
expect(target.error).toHaveBeenCalledWith("[matrix_rtc::a] boom");
expect(target.warn).toHaveBeenCalledWith("[matrix_rtc::a] hm");
expect(target.info).toHaveBeenCalledWith("[matrix_rtc::a] fyi");
expect(target.debug).toHaveBeenCalledWith("[matrix_rtc::a] dbg");
expect(target.trace).toHaveBeenCalledWith("[matrix_rtc::a] trc");
});
});
/** A driver that answers every read with nothing and never sends. */
+6
View File
@@ -18,6 +18,7 @@ Please see LICENSE in the repository root for full details.
import initAsync, { type InitInput } from "./generated/wasm-bindgen/index.js";
import bindings from "./generated/matrix_rtc";
import { installMatrixRtcLogSink } from "./logSink";
export {
FfiMatrixDriver,
@@ -31,6 +32,8 @@ export {
FfiImpairment,
FfiJoinError,
FfiKeepAlive,
FfiLogLevel,
setLogSink,
FfiMembershipState,
FfiTransportIntent,
RtcError,
@@ -54,6 +57,7 @@ export type {
FfiToDeviceRecipient,
FfiTransportDelegationRequest,
ConnectivitySinkLike,
LogSink,
MatrixDriverCallback,
RoomEventSinkLike,
StateUpdateSinkLike,
@@ -83,6 +87,8 @@ export async function initMatrixRtcSdk(
loading ??= (async (): Promise<void> => {
await initAsync({ module_or_path: source ?? (await bundledWasm()) });
bindings.initialize();
// The crate is silent until told where to log.
installMatrixRtcLogSink();
})();
await loading;
}
+55
View File
@@ -0,0 +1,55 @@
/*
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 Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { FfiLogLevel, type LogSink, setLogSink } from "./generated/matrix_rtc";
/**
* A {@link LogSink} that writes the crate's lines to a matrix-js-sdk logger,
* so they land in the same console and rageshake as everything else, under
* the logger's own level filtering. Each line names the Rust module it came
* from.
*/
export function matrixRtcLogSink(target: Logger): LogSink {
return {
log(level, module, message): void {
const line = `[${module}] ${message}`;
switch (level) {
case FfiLogLevel.Error:
target.error(line);
break;
case FfiLogLevel.Warn:
target.warn(line);
break;
case FfiLogLevel.Info:
target.info(line);
break;
case FfiLogLevel.Debug:
target.debug(line);
break;
case FfiLogLevel.Trace:
target.trace(line);
break;
}
},
};
}
/**
* Routes the crate's log lines to `[matrix-rtc]` under Element Call's root
* logger. `maxLevel` is the crate-side cut-off: what it does not format it
* does not send, so trace stays off unless asked for.
*/
export function installMatrixRtcLogSink(
maxLevel: FfiLogLevel = FfiLogLevel.Debug,
): void {
setLogSink(
matrixRtcLogSink(rootLogger.getChild("[matrix-rust-rtc]")),
maxLevel,
);
}
+33
View File
@@ -0,0 +1,33 @@
/*
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 { useEffect, useState } from "react";
import {
type OwnProfile,
type ProfileDriver,
} from "../driver/ElementCallMatrixClientDriver";
import { useMatrixDrivers } from "../driver/MatrixDriverContext";
/** The user's own display name and avatar from a profile driver, kept current. */
export function useOwnProfileFrom(profile: ProfileDriver): OwnProfile {
const [own, setOwn] = useState(() => profile.getOwnProfile());
useEffect(() => {
setOwn(profile.getOwnProfile());
return profile.subscribeOwnProfile(setOwn);
}, [profile]);
return own;
}
/**
* {@link useOwnProfileFrom} over the client driver the host provided. The
* read-only counterpart of `useProfile(client)`, for the call tree; editing
* stays with the profile settings, which a host may not offer.
*/
export function useOwnProfile(): OwnProfile {
return useOwnProfileFrom(useMatrixDrivers().clientDriver);
}
@@ -0,0 +1,158 @@
/*
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 { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import { testScope } from "../utils/test";
import { FakeParticipation, fakeMembership } from "../utils/test-participation";
import { ElementCallReactionEventType, type RaisedHandInfo } from ".";
import { ParticipationReactionsReader } from "./ParticipationReactionsReader";
const alice = fakeMembership({
member: {
memberId: "m-alice",
userId: "@alice:example.org",
deviceId: "ALICE",
eventId: "$alice-join",
},
});
const aliceId = "@alice:example.org:ALICE";
function raisedHand(
timeline: MockElementCallMatrixClientDriver,
member = alice,
eventId = "$hand",
): void {
timeline.emitTimelineEvent({
eventId,
type: "m.reaction",
sender: member.member.userId,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: member.member.eventId,
key: "🖐️",
},
},
originServerTs: 1000,
});
}
function setUp(): {
participation: FakeParticipation;
timeline: MockElementCallMatrixClientDriver;
hands: () => Record<string, RaisedHandInfo>;
reactions: () => string[];
} {
const participation = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver();
const reader = new ParticipationReactionsReader(
testScope(),
participation,
timeline,
);
let hands: Record<string, RaisedHandInfo> = {};
reader.raisedHands$.subscribe((h) => (hands = h));
let reactions: string[] = [];
reader.reactions$.subscribe(
(r) => (reactions = Object.values(r).map((v) => v.reactionOption.emoji)),
);
return {
participation,
timeline,
hands: () => hands,
reactions: () => reactions,
};
}
describe("ParticipationReactionsReader", () => {
it("raises and lowers a hand with the member's reaction and its redaction", () => {
const { participation, timeline, hands } = setUp();
participation.setMemberships([alice]);
raisedHand(timeline);
expect(hands()).toEqual({
[aliceId]: {
membershipEventId: "$alice-join",
reactionEventId: "$hand",
time: new Date(1000),
},
});
timeline.emitTimelineEvent({
eventId: "$redaction",
type: "m.room.redaction",
sender: alice.member.userId,
content: {},
originServerTs: 2000,
redacts: "$hand",
});
expect(hands()).toEqual({});
});
it("ignores a reaction that does not relate to the sender's own membership", () => {
const { participation, timeline, hands } = setUp();
participation.setMemberships([alice]);
timeline.emitTimelineEvent({
eventId: "$forged",
type: "m.reaction",
sender: "@mallory:example.org",
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$alice-join",
key: "🖐️",
},
},
originServerTs: 1000,
});
expect(hands()).toEqual({});
});
it("picks up a hand raised before we looked, and drops it when the member leaves", () => {
const { participation, timeline, hands } = setUp();
// The reaction is already in the room when the roster arrives.
raisedHand(timeline);
expect(hands()).toEqual({});
participation.setMemberships([alice]);
expect(Object.keys(hands())).toEqual([aliceId]);
participation.setMemberships([]);
expect(hands()).toEqual({});
});
it("re-resolves a hand when the member re-sends their membership", () => {
const { participation, timeline, hands } = setUp();
participation.setMemberships([alice]);
raisedHand(timeline);
expect(Object.keys(hands())).toEqual([aliceId]);
// A new membership event without a hand on it: the hand goes.
const resent = fakeMembership({
member: { ...alice.member, eventId: "$alice-join-2" },
});
participation.setMemberships([resent]);
expect(hands()).toEqual({});
// Raised again on the new event: back.
raisedHand(timeline, resent, "$hand-2");
expect(hands()[aliceId]?.membershipEventId).toBe("$alice-join-2");
});
it("shows a reaction keyed by the member's media id", () => {
const { participation, timeline, reactions } = setUp();
participation.setMemberships([alice]);
timeline.emitTimelineEvent({
eventId: "$reaction",
type: ElementCallReactionEventType,
sender: alice.member.userId,
content: {
"m.relates_to": { rel_type: "m.reference", event_id: "$alice-join" },
emoji: "🎉",
name: "party",
},
originServerTs: 1000,
});
expect(reactions()).toEqual(["🎉"]);
});
});
@@ -0,0 +1,250 @@
/*
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, delay } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import {
ElementCallReactionEventType,
GenericReaction,
type RaisedHandInfo,
type ReactionInfo,
ReactionSet,
} from ".";
import { REACTION_ACTIVE_TIME_MS } from "./ReactionsReader";
import { type Behavior } from "../state/Behavior";
import { type Epoch, type ObservableScope } from "../state/ObservableScope";
import { type FfiMembership } from "../matrix-rtc-sdk";
import {
type TimelineDriver,
type TimelineEvent,
} from "../driver/ElementCallMatrixClientDriver";
import { memberMediaId } from "../state/rtc/mediaId";
const RAISED_HAND_KEY = "🖐️";
const REACTION_EVENT_TYPE = "m.reaction";
const REDACTION_EVENT_TYPE = "m.room.redaction";
/** What the reader needs from a {@link CallParticipation}. */
export interface ParticipationReactionsSource {
memberships$: Behavior<Epoch<FfiMembership[]>>;
}
interface Relation {
rel_type?: string;
event_id?: string;
key?: string;
}
function relationOf(content: Record<string, unknown>): Relation | undefined {
return content["m.relates_to"] as Relation | undefined;
}
/**
* Raised hands and reactions over a {@link CallParticipation} and the client
* driver's timeline: the counterpart of {@link ReactionsReader}, which reads
* the same from a matrix-js-sdk session.
*
* Hands and reactions relate to the sender's current membership event, as
* they always have. Both outputs are keyed by the member's media id
* (`${userId}:${deviceId}`), which is what the tiles are keyed by.
*/
export class ParticipationReactionsReader {
private readonly raisedHandsSubject$ = new BehaviorSubject<
Record<string, RaisedHandInfo>
>({});
public readonly raisedHands$ = this.raisedHandsSubject$.asObservable();
private readonly reactionsSubject$ = new BehaviorSubject<
Record<string, ReactionInfo>
>({});
public readonly reactions$ = this.reactionsSubject$.asObservable();
private memberships: FfiMembership[] = [];
public constructor(
scope: ObservableScope,
participation: ParticipationReactionsSource,
private readonly timeline: Pick<
TimelineDriver,
"subscribeTimeline" | "getRelatedEvents"
>,
) {
// Hide reactions after a given time.
this.reactionsSubject$
.pipe(delay(REACTION_ACTIVE_TIME_MS), scope.bind())
.subscribe((reactions) => {
const date = new Date();
const nextEntries = Object.fromEntries(
Object.entries(reactions).filter(([_, hr]) => hr.expireAfter > date),
);
if (Object.keys(reactions).length === Object.keys(nextEntries).length)
return;
this.reactionsSubject$.next(nextEntries);
});
scope.onEnd(timeline.subscribeTimeline(this.handleEvent));
participation.memberships$
.pipe(scope.bind())
.subscribe((memberships) => this.onMembershipsChanged(memberships.value));
}
/** The member whose current membership event is `eventId`, sent by `sender`. */
private memberFor(
eventId: string | undefined,
sender: string,
): FfiMembership | undefined {
if (eventId === undefined) return undefined;
return this.memberships.find(
(m) => m.member.eventId === eventId && m.member.userId === sender,
);
}
/** The hand `member` has raised on their current membership event, if any. */
private findRaisedHand(member: FfiMembership): RaisedHandInfo | undefined {
const eventId = member.member.eventId;
if (eventId === undefined) return undefined;
const reaction = this.timeline
.getRelatedEvents(eventId, "m.annotation", REACTION_EVENT_TYPE)
.find(
(e) =>
e.sender === member.member.userId &&
relationOf(e.content)?.key === RAISED_HAND_KEY,
);
if (reaction === undefined) return undefined;
return {
membershipEventId: eventId,
reactionEventId: reaction.eventId,
time: new Date(reaction.originServerTs),
};
}
/**
* Drops the hands of members who left, keeps a hand across a re-sent
* membership when it was raised again on the new event, and picks up hands
* that were raised before we looked.
*/
private onMembershipsChanged(memberships: FfiMembership[]): void {
this.memberships = memberships;
const present = new Map(
memberships.map((m) => [memberMediaId(m.member), m]),
);
const hands = { ...this.raisedHandsSubject$.value };
let changed = false;
for (const id of Object.keys(hands)) {
const member = present.get(id);
if (member === undefined) {
delete hands[id];
changed = true;
} else if (hands[id].membershipEventId !== member.member.eventId) {
// The member re-sent their membership: the hand stands only if it
// was raised on the new event too.
const hand = this.findRaisedHand(member);
if (hand) hands[id] = hand;
else delete hands[id];
changed = true;
}
}
for (const [id, member] of present) {
if (id in hands) continue;
const hand = this.findRaisedHand(member);
if (hand) {
hands[id] = hand;
changed = true;
}
}
if (changed) this.raisedHandsSubject$.next(hands);
}
private addRaisedHand(identifier: string, info: RaisedHandInfo): void {
this.raisedHandsSubject$.next({
...this.raisedHandsSubject$.value,
[identifier]: info,
});
}
private removeRaisedHand(identifier: string): void {
this.raisedHandsSubject$.next(
Object.fromEntries(
Object.entries(this.raisedHandsSubject$.value).filter(
([id]) => id !== identifier,
),
),
);
}
private handleEvent = (event: TimelineEvent): void => {
if (event.type === ElementCallReactionEventType) {
const relation = relationOf(event.content);
const member = this.memberFor(relation?.event_id, event.sender);
if (member === undefined) {
logger.warn(
`Reaction target was not a membership event for ${event.sender}, ignoring`,
);
return;
}
const identifier = memberMediaId(member.member);
const rawEmoji = event.content.emoji;
if (typeof rawEmoji !== "string" || rawEmoji === "") {
logger.warn(`Reaction had no emoji from ${event.eventId}`);
return;
}
const emoji = new Intl.Segmenter(undefined, { granularity: "grapheme" })
.segment(rawEmoji)
[Symbol.iterator]()
.next().value?.segment;
if (!emoji?.trim()) {
logger.warn(
`Reaction had no emoji from ${event.eventId} after splitting`,
);
return;
}
const reaction = {
...GenericReaction,
emoji,
// If we don't find a reaction, we can fallback to the generic sound.
...ReactionSet.find((r) => r.name === event.content.name),
};
const current = this.reactionsSubject$.value;
if (current[identifier]) {
// We've still got a reaction from this user, ignore it to prevent spamming
logger.warn(`Got reaction from ${identifier} but one is still playing`);
return;
}
this.reactionsSubject$.next({
...current,
[identifier]: {
reactionOption: reaction,
expireAfter: new Date(Date.now() + REACTION_ACTIVE_TIME_MS),
},
});
} else if (event.type === REACTION_EVENT_TYPE) {
const relation = relationOf(event.content);
const member = this.memberFor(relation?.event_id, event.sender);
if (member === undefined) {
logger.warn(
`Reaction target was not a membership event for ${event.sender}, ignoring`,
);
return;
}
if (relation?.key === RAISED_HAND_KEY)
this.addRaisedHand(memberMediaId(member.member), {
reactionEventId: event.eventId,
membershipEventId: relation.event_id!,
time: new Date(event.originServerTs),
});
} else if (event.type === REDACTION_EVENT_TYPE) {
const redacts =
event.redacts ?? (event.content.redacts as string | undefined);
const target = Object.entries(this.raisedHandsSubject$.value).find(
([, hand]) => hand.reactionEventId === redacts,
)?.[0];
if (target !== undefined) this.removeRaisedHand(target);
}
};
}
+62 -72
View File
@@ -5,20 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventType, RelationType } from "matrix-js-sdk";
import { EventType, type MatrixClient, RelationType } from "matrix-js-sdk";
import {
createContext,
use,
type ReactNode,
useCallback,
useMemo,
type JSX,
} from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext";
import { type TimelineDriver } from "../driver/ElementCallMatrixClientDriver";
import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
@@ -44,116 +42,108 @@ export const useReactionsSender = (): ReactionsSenderContextType => {
/**
* Provider that handles sending a reaction or hand raised event to a call.
*/
/** How to send and take back a reaction: a room event, and a redaction. */
export type ReactionsTimeline = Pick<
TimelineDriver,
"sendRoomEvent" | "redactEvent"
>;
/** {@link ReactionsTimeline} over a matrix-js-sdk client. */
export function jsSdkReactionsTimeline(
client: Pick<MatrixClient, "sendEvent" | "redactEvent">,
roomId: string,
): ReactionsTimeline {
return {
sendRoomEvent: async (eventType, content) => {
const { event_id: eventId } = await client.sendEvent(
roomId,
eventType as never,
content as never,
);
return { eventId };
},
redactEvent: async (eventId) => {
await client.redactEvent(roomId, eventId);
},
};
}
export const ReactionsSenderProvider = ({
children,
rtcSession,
vm,
ownIdentifier,
ownMembershipEventId,
timeline,
}: {
children: ReactNode;
rtcSession: MatrixRTCSession;
vm: CallViewModel;
/** Our key in `vm.reactions$` / `vm.handsRaised$` (`${userId}:${deviceId}`). */
ownIdentifier: string;
/** The event id of our current membership, which reactions relate to. */
ownMembershipEventId: string | undefined;
timeline: ReactionsTimeline;
}): JSX.Element => {
const memberships = useMatrixRTCSessionMemberships(rtcSession);
// A widget host may forbid reactions; without a client state (the
// component, the crate path) there is nobody to forbid them.
const clientState = useClientState();
const supportsReactions =
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
const room = rtcSession.room;
const myUserId = room.client.getUserId();
const myDeviceId = room.client.getDeviceId();
const myMembershipIdentifier = `${myUserId}:${myDeviceId}`;
const myMembershipEvent = useMemo(
() =>
memberships.find(
(m) => m.userId === myUserId && m.deviceId === myDeviceId,
)?.eventId,
[memberships, myUserId, myDeviceId],
);
clientState === undefined ||
(clientState.state === "valid" && clientState.supportedFeatures.reactions);
const reactions = useBehavior(vm.reactions$);
const myReaction = useMemo(
() =>
myMembershipIdentifier !== undefined
? reactions[myMembershipIdentifier]
: undefined,
[myMembershipIdentifier, reactions],
);
const myReaction = reactions[ownIdentifier];
const handsRaised = useBehavior(vm.handsRaised$);
const myRaisedHand = useMemo(
() =>
myMembershipIdentifier !== undefined
? handsRaised[myMembershipIdentifier]
: undefined,
[myMembershipIdentifier, handsRaised],
);
const myRaisedHand = handsRaised[ownIdentifier];
const toggleRaisedHand = useCallback(async () => {
if (!myMembershipIdentifier) {
return;
}
const myReactionId = myRaisedHand?.reactionEventId;
if (!myReactionId) {
try {
if (!myMembershipEvent) {
if (!ownMembershipEventId) {
throw new Error("Cannot find own membership event");
}
const reaction = await room.client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: myMembershipEvent,
key: "🖐️",
},
const { eventId } = await timeline.sendRoomEvent(EventType.Reaction, {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: ownMembershipEventId,
key: "🖐️",
},
);
logger.debug("Sent raise hand event", reaction.event_id);
});
logger.debug("Sent raise hand event", eventId);
} catch (ex) {
logger.error("Failed to send raised hand", ex);
}
} else {
try {
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
await timeline.redactEvent(myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
throw ex;
}
}
}, [
myMembershipEvent,
myMembershipIdentifier,
myRaisedHand,
rtcSession,
room,
]);
}, [ownMembershipEventId, myRaisedHand, timeline]);
const sendReaction = useCallback(
async (reaction: ReactionOption) => {
if (!myMembershipIdentifier || myReaction) {
// We're still reacting
if (myReaction) {
return;
}
if (!myMembershipEvent) {
if (!ownMembershipEventId) {
throw new Error("Cannot find own membership event");
}
await room.client.sendEvent(
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: myMembershipEvent,
},
emoji: reaction.emoji,
name: reaction.name,
await timeline.sendRoomEvent(ElementCallReactionEventType, {
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: ownMembershipEventId,
},
);
emoji: reaction.emoji,
name: reaction.name,
});
},
[myMembershipEvent, myReaction, room, myMembershipIdentifier, rtcSession],
[ownMembershipEventId, myReaction, timeline],
);
return (
+2 -5
View File
@@ -6,13 +6,12 @@ Please see LICENSE in the repository root for full details.
*/
import { type FC, type FormEventHandler, useCallback, useState } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { Trans, useTranslation } from "react-i18next";
import { Button, Heading, Text } from "@vector-im/compound-web";
import styles from "./CallEndedView.module.css";
import feedbackStyle from "../input/FeedbackInput.module.css";
import { useProfile } from "../profile/useProfile";
import { useOwnProfile } from "../profile/useOwnProfile";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input";
@@ -22,7 +21,6 @@ import { LeaveToHomeLink } from "../button/LeaveToHomeLink";
import { useLeaveToHome } from "../LeaveToHomeContext";
interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
hideHeader: boolean;
confineToRoom: boolean;
@@ -30,7 +28,6 @@ interface Props {
}
export const CallEndedView: FC<Props> = ({
client,
isPasswordlessUser,
hideHeader,
confineToRoom,
@@ -39,7 +36,7 @@ export const CallEndedView: FC<Props> = ({
const { t } = useTranslation();
const leaveToHome = useLeaveToHome();
const { displayName } = useProfile(client);
const { displayName } = useOwnProfile();
const [surveySubmitted, setSurveySubmitted] = useState(false);
const [starRating, setStarRating] = useState(0);
const [submitting, setSubmitting] = useState(false);
+225
View File
@@ -0,0 +1,225 @@
/*
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.
*/
/**
* `CallView` over the mock drivers: no matrix-js-sdk client, the Rust crate
* carrying the call. The lobby and the ended screen render as they would for
* a host; the in-call screen needs a LiveKit SFU, which no mock provides yet,
* so its story shows the path a call takes when the homeserver advertises no
* transport instead.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, type ReactNode, useEffect, useMemo, useState } from "react";
import { BrowserRouter } from "react-router-dom";
import { CallView } from "./CallView";
import { CallEndedView } from "./CallEndedView";
import {
type MatrixDrivers,
MatrixDriverProvider,
} from "../driver/MatrixDriverContext";
import { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import {
MockRtcMatrixDriver,
type MockRtcMatrixDriverOptions,
slotEvent,
} from "../driver/MockRtcMatrixDriver";
import { HostBridgeProvider, nullHostBridge } from "../HostBridge";
import { RootElementProvider } from "../RootElementContext";
import {
componentProperties,
configurationForIntent,
type UrlParams,
UrlParamsProvider,
UserIntent,
} from "../UrlParams";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { ObservableScope } from "../state/ObservableScope";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
const ROOM_ID = "!story:example.org";
const USER_ID = "@me:example.org";
const DEVICE_ID = "STORYDEV";
const alice = {
userId: "@alice:example.org",
deviceId: "ALICEDEV",
memberId: "m-alice",
};
interface HostProps {
intent: UserIntent;
/** Members already in the call when the view mounts. */
peers?: (typeof alice)[];
/** The RTC driver's options, e.g. no transports for the error path. */
rtc?: MockRtcMatrixDriverOptions;
children: ReactNode;
}
/**
* What a host provides around a call: the drivers, the host bridge, the
* call's parameters, the media devices and the root element.
*/
const Host: FC<HostProps> = ({ intent, peers = [], rtc, children }) => {
const drivers = useMemo((): MatrixDrivers => {
const rtcDriver = new MockRtcMatrixDriver({
userId: USER_ID,
deviceId: DEVICE_ID,
roomId: ROOM_ID,
roomState: [slotEvent({ roomId: ROOM_ID, status: "open" })],
...rtc,
});
for (const peer of peers) rtcDriver.addPeer(peer);
return {
rtcDriver,
clientDriver: new MockElementCallMatrixClientDriver({
userId: USER_ID,
deviceId: DEVICE_ID,
roomId: ROOM_ID,
roomInfo: { name: "Weekly sync", joinRule: "public" },
ownProfile: { displayName: "Me", avatarUrl: null },
members: [
{
userId: USER_ID,
displayName: "Me",
avatarUrl: null,
membership: "join",
},
...peers.map((p) => ({
userId: p.userId,
displayName: p.userId.slice(1).split(":")[0],
avatarUrl: null,
membership: "join" as const,
})),
],
}),
};
}, [peers, rtc]);
const params = useMemo(
(): UrlParams => ({
...componentProperties,
roomId: ROOM_ID,
...configurationForIntent(intent),
}),
[intent],
);
// The peers join once the crate listens to the room: the participation is
// created after the wasm has loaded, so poll for its sink.
useEffect(() => {
if (peers.length === 0) return;
const rtcDriver = drivers.rtcDriver as MockRtcMatrixDriver;
let tries = 0;
const timer = setInterval(() => {
tries++;
try {
for (const peer of peers) rtcDriver.peerJoins(peer);
clearInterval(timer);
} catch {
if (tries > 200) clearInterval(timer);
}
}, 50);
return (): void => clearInterval(timer);
}, [drivers, peers]);
const [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setMediaDevices(new MediaDevices(scope, { controlledAudioDevices: false }));
return (): void => {
setMediaDevices(null);
scope.end();
};
}, []);
const [root, setRoot] = useState<HTMLDivElement | null>(null);
return (
<BrowserRouter>
<HostBridgeProvider value={nullHostBridge}>
<UrlParamsProvider value={params}>
<MatrixDriverProvider value={drivers}>
<div
ref={setRoot}
style={{ width: "80vw", height: "80vh", position: "relative" }}
>
{root !== null && mediaDevices !== null && (
<RootElementProvider value={root}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<>{children}</>
</ProcessorProvider>
</MediaDevicesContext>
</RootElementProvider>
)}
</div>
</MatrixDriverProvider>
</UrlParamsProvider>
</HostBridgeProvider>
</BrowserRouter>
);
};
const meta: Meta<typeof CallView> = {
title: "Room/CallView",
component: CallView,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof CallView>;
/** The lobby of a call that Alice is already in. */
export const Lobby: Story = {
args: {
isPasswordlessUser: false,
confineToRoom: true,
preload: false,
skipLobby: false,
},
render: (args) => (
<Host intent={UserIntent.JoinExistingCall} peers={[alice]}>
<CallView {...args} />
</Host>
),
};
/**
* Straight into the call, with a homeserver that advertises no transport:
* the crate fails the join and the view shows the error it turns into.
*/
export const NoTransport: Story = {
args: {
isPasswordlessUser: false,
confineToRoom: true,
preload: false,
skipLobby: true,
},
render: (args) => (
<Host intent={UserIntent.StartNewCall} rtc={{ transports: [] }}>
<CallView {...args} />
</Host>
),
};
/** The screen after leaving, as a passwordless user sees it. */
export const Ended: Story = {
args: {
isPasswordlessUser: true,
confineToRoom: false,
preload: false,
skipLobby: false,
},
render: (args) => (
<Host intent={UserIntent.JoinExistingCall}>
<CallEndedView
endedCallId={ROOM_ID}
isPasswordlessUser={args.isPasswordlessUser}
hideHeader={false}
confineToRoom={args.confineToRoom}
/>
</Host>
),
};
+81 -7
View File
@@ -11,6 +11,7 @@ Please see LICENSE in the repository root for full details.
import {
beforeEach,
describe,
expect,
type MockedFunction,
onTestFinished,
@@ -64,6 +65,15 @@ import { MatrixRTCTransportMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { constant } from "../state/Behavior";
import {
type MatrixDrivers,
MatrixDriverProvider,
} from "../driver/MatrixDriverContext";
import { MockRtcMatrixDriver } from "../driver/MockRtcMatrixDriver";
import { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
import { callViewModelImplementation } from "../settings/settings";
import { CallViewModelImplementation } from "../config/ConfigOptions";
vi.mock("../soundUtils");
vi.mock("../useAudioContext");
@@ -138,6 +148,23 @@ beforeEach(() => {
);
});
/** Mock drivers over the same room and identity as the mocked session. */
function defaultDrivers(): MatrixDrivers {
return {
rtcDriver: new MockRtcMatrixDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
}),
clientDriver: new MockElementCallMatrixClientDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
roomInfo: { joinRule: "invite" },
}),
};
}
function createCallView(
hostBridge: HostBridge,
joined = true,
@@ -145,6 +172,8 @@ function createCallView(
withErrorBoundary?: boolean;
/** Wait for the host to say when to join, rather than joining at once. */
preload?: boolean;
/** The host's drivers, for the matrix-rtc implementation. */
drivers?: MatrixDrivers;
} = {},
): {
rtcSession: MatrixRTCSession;
@@ -194,13 +223,15 @@ function createCallView(
<TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
{callView}
</GroupCallErrorBoundary>
) : (
callView
)}
<MatrixDriverProvider value={options.drivers ?? defaultDrivers()}>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
{callView}
</GroupCallErrorBoundary>
) : (
callView
)}
</MatrixDriverProvider>
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider>
@@ -424,3 +455,46 @@ test("user can reconnect after a membership manager error", async () => {
// In-call controls should be visible again
await waitFor(() => screen.getByRole("button", { name: "Leave" }));
});
describe("the call implementation switch", () => {
test("matrix-js-sdk carries the call by default: no participation", async () => {
createCallView(nullHostBridge);
await waitFor(() => expect(ActiveCall).toHaveBeenCalled());
expect(
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].participation,
).toBeNull();
expect(window.matrixRtc).toBeUndefined();
});
test("with matrix-rtc selected and drivers provided, the crate carries the call", async () => {
await initMatrixRtcSdkForTests();
callViewModelImplementation.setValue(CallViewModelImplementation.MatrixRtc);
onTestFinished(() =>
callViewModelImplementation.setValue(
CallViewModelImplementation.MatrixJsSdk,
),
);
const drivers: MatrixDrivers = {
rtcDriver: new MockRtcMatrixDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
}),
clientDriver: new MockElementCallMatrixClientDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
}),
};
createCallView(nullHostBridge, true, { drivers });
await waitFor(() =>
expect(
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].participation,
).not.toBeNull(),
);
const { participation } = vi.mocked(ActiveCall).mock.calls.at(-1)![0];
expect(window.matrixRtc?.participation).toBe(participation);
// The participation is bound to the drivers' room and identity.
expect(participation?.session$.value.roomId).toBe(roomId);
});
});
+178 -71
View File
@@ -12,11 +12,11 @@ import {
useEffect,
useMemo,
useState,
useRef,
} from "react";
import {
type MatrixClient,
JoinRule,
type Room,
UnsupportedStickyEventsEndpointError,
} from "matrix-js-sdk";
import {
@@ -34,7 +34,11 @@ import { LobbyView } from "./LobbyView";
import { type MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile";
import {
mediaKeyStatisticsOf,
NO_MEDIA_KEY_STATISTICS,
} from "../analytics/PosthogEvents";
import { useOwnProfile } from "../profile/useOwnProfile";
import { findDeviceByName } from "../utils/media";
import { ActiveCall } from "./InCallView";
import { type MuteStates } from "../state/MuteStates";
@@ -42,11 +46,9 @@ import { useMediaDevices } from "../MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import {
saveKeyForRoom,
useRoomEncryptionSystem,
useEncryptionSystemFor,
} from "../e2ee/sharedKeyManagement";
import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { useRoomInfo } from "./useRoomInfo";
import { InviteModal } from "./InviteModal";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
@@ -64,7 +66,6 @@ import {
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
@@ -72,6 +73,20 @@ import { useRootElement } from "../RootElementContext.ts";
import { useHostBridge } from "../HostBridge.ts";
import { useMuteStates } from "../state/useMuteStates.ts";
import { useLeaveToHome } from "../LeaveToHomeContext.ts";
import { useMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
import { useCallParticipation } from "../state/rtc/useCallParticipation.ts";
import { type CallParticipation } from "../state/rtc/CallParticipation.ts";
import { participationConfig } from "../state/rtc/joinParams.ts";
import { effectiveCallViewModelImplementation } from "../state/rtc/implementation.ts";
import {
CallViewModelImplementation,
type MatrixRTCMode,
} from "../config/ConfigOptions.ts";
import { Config } from "../config/Config.ts";
import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings.ts";
import { constant } from "../state/Behavior.ts";
import { Epoch } from "../state/ObservableScope.ts";
import { FfiStatus, type FfiMembership } from "../matrix-rtc-sdk";
/**
* If there already are this many participants in the call, we automatically mute
@@ -82,14 +97,26 @@ export const MUTE_PARTICIPANT_COUNT = 8;
declare global {
interface Window {
rtcSession?: MatrixRTCSession;
/** The crate's participation, when the Rust implementation carries the call. */
matrixRtc?: { participation: CallParticipation };
}
}
/** What the crate's roster reads as while matrix-js-sdk carries the call. */
const NO_PARTICIPATION_MEMBERSHIPS = constant(
new Epoch<FfiMembership[]>([], 0),
);
interface Props {
/** The client to place the call with. */
client: MatrixClient;
/**
* The matrix-js-sdk client and session, for the matrix-js-sdk
* implementation of the call. Without them the Rust crate carries the call
* through the drivers whatever the developer setting says (the component
* has no client to offer).
*/
client?: MatrixClient;
/** The call to join. */
rtcSession: MatrixRTCSession;
rtcSession?: MatrixRTCSession;
/**
* Whether the user is signed in as a guest, and so should be offered the
* chance to create an account when the call ends.
@@ -161,7 +188,52 @@ const LoadedCallView: FC<LoadedProps> = ({
const [externalError, setExternalError] = useState<ElementCallError | null>(
null,
);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession);
// The host's drivers: what the room is called and looks like, who we are,
// and (on the crate path) the session itself come from them.
const drivers = useMatrixDrivers();
const { roomId } = drivers.clientDriver;
const roomInfo = useRoomInfo();
const e2eeSystem = useEncryptionSystemFor(roomId, roomInfo.encrypted);
// Which implementation carries this call, sampled once for the view's
// lifetime (§5.15 of the oxidation plan): the Rust crate through the
// host's drivers, or matrix-js-sdk's session — which needs a session.
const [implementation] = useState(() =>
effectiveCallViewModelImplementation(),
);
const useMatrixRtc =
implementation === CallViewModelImplementation.MatrixRtc ||
rtcSession === undefined;
// Sampled once, like the implementation: the participation is the call,
// and rebuilding it for a later change of these would leave and rejoin.
const [participationConfigValue] = useState(() =>
useMatrixRtc
? participationConfig({
// matrix_rtc_mode in config.json overrides the user's choice.
mode:
(Config.get().matrix_rtc_mode as MatrixRTCMode | undefined) ??
matrixRTCModeSetting.value$.value,
manageMediaKeys: e2eeSystem.kind === E2eeType.PER_PARTICIPANT,
session: Config.get().matrix_rtc_session,
})
: null,
);
const participation = useCallParticipation(
useMatrixRtc ? drivers : null,
participationConfigValue,
);
const participationMemberships = useBehavior(
participation?.memberships$ ?? NO_PARTICIPATION_MEMBERSHIPS,
);
// The call's members, whichever side lists them; only who they are matters here.
const memberUserIds = useMemo(
() =>
useMatrixRtc
? participationMemberships.value.map((m) => m.member.userId)
: jsSdkMemberships.map((m) => m.userId!),
[useMatrixRtc, participationMemberships, jsSdkMemberships],
);
const rootElement = useRootElement();
const hostBridge = useHostBridge();
// A host that can close us is a host that decides when we stop existing, so
@@ -178,12 +250,16 @@ const LoadedCallView: FC<LoadedProps> = ({
muted: muteAllAudio,
}),
);
// This should use `useEffectEvent` (only available in experimental versions)
// Joining a big call starts muted. Decided once, the first time the roster
// is known: at mount for matrix-js-sdk, after the seed for the crate.
const mutedForCallSize = useRef(false);
useEffect(() => {
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
if (mutedForCallSize.current) return;
if (useMatrixRtc && !participationMemberships.value.length) return;
mutedForCallSize.current = true;
if (memberUserIds.length >= MUTE_PARTICIPANT_COUNT)
muteStates.audio.setEnabled$.value?.(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [useMatrixRtc, participationMemberships, memberUserIds, muteStates]);
useEffect(() => {
logger.info("[Lifecycle] CallView Component mounted");
@@ -203,18 +279,20 @@ const LoadedCallView: FC<LoadedProps> = ({
}, [rootElement]);
useEffect(() => {
window.rtcSession = rtcSession;
if (rtcSession !== undefined) window.rtcSession = rtcSession;
if (participation !== null) window.matrixRtc = { participation };
return (): void => {
delete window.rtcSession;
delete window.matrixRtc;
};
}, [rtcSession]);
}, [rtcSession, participation]);
// TODO move this into the callViewModel LocalMembership.ts
// We might actually not need this at all. Since we get into fatalError on those errors already?
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => {
// matrix-js-sdk only; the crate reports these through `fatalError$`.
useEffect(() => {
if (rtcSession === undefined) return;
const onError = (error: unknown): void => {
// When matrix_rtc_mode=matrix_2_0 is in effect but the homeserver does
// not advertise MSC4354 (sticky events), the SDK throws an
// `UnsupportedStickyEventsEndpointError`. The MembershipManager
@@ -227,79 +305,93 @@ const LoadedCallView: FC<LoadedProps> = ({
} else {
setExternalError(new ConnectionLostError());
}
},
);
};
rtcSession.on(MatrixRTCSessionEvent.MembershipManagerError, onError);
return (): void => {
rtcSession.off(MatrixRTCSessionEvent.MembershipManagerError, onError);
};
}, [rtcSession]);
useEffect(() => {
if (client === undefined || rtcSession === undefined) return;
// Sanity check the room object
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
logger.warn(
`We've ended up with multiple rooms for the same ID (${rtcSession.room.roomId}). This indicates a bug in the group call loading code, and may lead to incomplete room state.`,
);
}, [client, rtcSession.room]);
}, [client, rtcSession]);
const room = rtcSession.room as Room;
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const { displayName, avatarUrl } = useOwnProfile();
const roomName = roomInfo.name;
const roomAvatar = roomInfo.avatarUrl;
const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
header,
} = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
// Save the password once we start the groupCallView
useEffect(() => {
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
}, [passwordFromUrl, room.roomId]);
if (passwordFromUrl) saveKeyForRoom(roomId, passwordFromUrl);
}, [passwordFromUrl, roomId]);
useAppBarTitle(roomName);
const { userId: ownUserId } = drivers.clientDriver;
const roomAlias = roomInfo.canonicalAlias;
const matrixInfo = useMemo((): MatrixInfo => {
return {
userId: client.getUserId()!,
displayName: displayName!,
avatarUrl: avatarUrl!,
roomId: room.roomId,
userId: ownUserId,
displayName: displayName ?? ownUserId,
avatarUrl: avatarUrl ?? "",
roomId,
roomName,
roomAlias: room.getCanonicalAlias(),
roomAlias,
roomAvatar,
e2eeSystem,
};
}, [client, displayName, avatarUrl, roomName, room, roomAvatar, e2eeSystem]);
}, [
ownUserId,
displayName,
avatarUrl,
roomName,
roomId,
roomAlias,
roomAvatar,
e2eeSystem,
]);
// Count each member only once, regardless of how many devices they use
const participantCount = useMemo(
() => new Set<string>(memberships.map((m) => m.userId!)).size,
[memberships],
() => new Set<string>(memberUserIds).size,
[memberUserIds],
);
const mediaDevices = useMediaDevices();
const latestMuteStates = useLatest(muteStates);
// Read at leave time, not a dependency: `onLeft` feeds the in-call view's
// effect, and a roster change must not rebuild the call.
const latestMemberUserIds = useLatest(memberUserIds);
const enterRTCSessionOrError = useCallback(
async (rtcSession: MatrixRTCSession): Promise<void> => {
try {
setJoined(true);
// TODO-MULTI-SFU what to do with error handling now that we don't use this function?
// @BillCarsonFr
} catch (e) {
if (e instanceof ElementCallError) {
setExternalError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
setExternalError(error);
}
const enterRTCSessionOrError = useCallback(async (): Promise<void> => {
try {
setJoined(true);
// TODO-MULTI-SFU what to do with error handling now that we don't use this function?
// @BillCarsonFr
} catch (e) {
if (e instanceof ElementCallError) {
setExternalError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
setExternalError(error);
}
return Promise.resolve();
},
[setJoined],
);
}
return Promise.resolve();
}, [setJoined]);
useEffect(() => {
const defaultDeviceSetup = async ({
@@ -409,10 +501,14 @@ const LoadedCallView: FC<LoadedProps> = ({
// queuing/batching of requests.
const sendInstantly = hostControlsLifetime;
PosthogAnalytics.instance.eventCallEnded.track(
room.roomId,
rtcSession.memberships.length,
roomId,
latestMemberUserIds.current.length,
sendInstantly,
rtcSession,
participation !== null
? participation.mediaKeyStatistics()
: rtcSession === undefined
? NO_MEDIA_KEY_STATISTICS
: mediaKeyStatisticsOf(rtcSession),
);
// Unfortunately the PostHog library provides no way to await the
// tracking of an event, but we don't really want it to hold up our
@@ -457,8 +553,10 @@ const LoadedCallView: FC<LoadedProps> = ({
leaveSoundContext,
hostBridge,
hostControlsLifetime,
room.roomId,
roomId,
latestMemberUserIds,
rtcSession,
participation,
isPasswordlessUser,
confineToRoom,
returnToLobby,
@@ -474,7 +572,7 @@ const LoadedCallView: FC<LoadedProps> = ({
});
}, [hostBridge, joined, rtcSession]);
const joinRule = useJoinRule(room);
const joinRule = roomInfo.joinRule;
const [shareModalOpen, setInviteModalOpen] = useState(false);
const onDismissInviteModal = useCallback(
@@ -495,7 +593,9 @@ const LoadedCallView: FC<LoadedProps> = ({
const shareModal = (
<InviteModal
room={room}
roomId={roomId}
roomName={roomName}
e2eeSystem={e2eeSystem}
open={shareModalOpen}
onDismiss={onDismissInviteModal}
/>
@@ -525,6 +625,10 @@ const LoadedCallView: FC<LoadedProps> = ({
throw externalError;
};
body = <ErrorComponent />;
} else if (joined && useMatrixRtc && participation === null) {
// Joined before the crate is ready (its wasm loads on first use): the
// call appears with the participation, a render later.
body = null;
} else if (joined) {
body = (
<>
@@ -532,8 +636,9 @@ const LoadedCallView: FC<LoadedProps> = ({
<ActiveCall
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room}
rtcSession={rtcSession}
participation={participation}
roomId={roomId}
onLeft={onLeft}
muteStates={muteStates}
e2eeSystem={e2eeSystem}
@@ -554,8 +659,7 @@ const LoadedCallView: FC<LoadedProps> = ({
if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) {
body = (
<CallEndedView
endedCallId={rtcSession.room.roomId}
client={client}
endedCallId={roomId}
isPasswordlessUser={isPasswordlessUser}
hideHeader={header === HeaderStyle.None}
confineToRoom={confineToRoom}
@@ -583,13 +687,16 @@ const LoadedCallView: FC<LoadedProps> = ({
setExternalError(null);
if (action == "reconnect") {
setLeft(false);
await enterRTCSessionOrError(rtcSession).catch((e) => {
await enterRTCSessionOrError().catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}
}}
onError={(_error) => {
if (rtcSession.isJoined()) onLeft("error");
const joinedViaCrate =
participation !== null &&
FfiStatus.Connected.instanceOf(participation.status$.value);
if (rtcSession?.isJoined() === true || joinedViaCrate) onLeft("error");
// If there is an error we need to be dismissible again. This is done in
// `onLeft` as well; we need it here explicitly in case
// rtcSession.isJoined is false.
+12 -5
View File
@@ -42,7 +42,10 @@ import {
type CallViewModelOptions,
} from "../state/CallViewModel/CallViewModel";
import { alice, local } from "../utils/test-fixtures";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import {
jsSdkReactionsTimeline,
ReactionsSenderProvider,
} from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
@@ -160,7 +163,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
footerVm={footerVm}
developerSettingsVm={developerSettingsVm}
matrixInfo={matrixInfo}
matrixRoom={room}
roomId={room.roomId}
onShareClick={null}
/>
);
@@ -172,7 +175,9 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
<MediaDevicesContext value={mediaDevices}>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession.asMockedSession()}
ownIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
ownMembershipEventId={undefined}
timeline={jsSdkReactionsTimeline(client, room.roomId)}
>
<TooltipProvider>
<RoomContext value={livekitRoom}>{content}</RoomContext>
@@ -248,7 +253,8 @@ describe("ActiveCall", () => {
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
matrixRoom={matrixRoom}
participation={null}
roomId={matrixRoom.roomId}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
onShareClick={null}
@@ -298,7 +304,8 @@ describe("ActiveCall", () => {
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
matrixRoom={matrixRoom}
participation={null}
roomId={matrixRoom.roomId}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
onShareClick={null}
+132 -39
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
import { type MatrixClient } from "matrix-js-sdk";
import {
type FC,
type PointerEvent as ReactPointerEvent,
@@ -44,6 +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";
@@ -86,12 +87,21 @@ import { ObservableScope } from "../state/ObservableScope.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
import { type CallParticipation } from "../state/rtc/CallParticipation.ts";
import { useOptionalMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
import { ParticipationReactionsReader } from "../reactions/ParticipationReactionsReader.ts";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
import { jsSdkReactionsTimeline } from "../reactions/useReactionsSender.tsx";
import { CallViewModelImplementation } from "../config/ConfigOptions.ts";
import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel.ts";
import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab.tsx";
import { type ViewModel } from "../state/ViewModel.ts";
import { RingingStatus } from "../tile/RingingStatus.tsx";
import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
/** What `ownMembership$` reads as when matrix-js-sdk carries the call. */
const NO_OWN_MEMBERSHIP = constant(null);
declare module "react" {
interface CSSProperties {
"--call-view-safe-area-inset-top"?: string;
@@ -104,6 +114,12 @@ export interface ActiveCallProps extends Omit<
"vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm"
> {
e2eeSystem: EncryptionSystem;
/**
* The crate's participation in the session when the Rust implementation
* carries this call (see `CallViewModelImplementation`); null when
* matrix-js-sdk's `rtcSession` does.
*/
participation: CallParticipation | null;
// TODO refactor those reasons into an enum
onLeft: (
reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error",
@@ -125,34 +141,77 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
// The element we have to draw the call in: the page, or the container a host
// gave us. Its size, not the window's, decides how the call is laid out.
const rootElement = useRootElement();
// The drivers, where a host provided them; required with a participation.
const drivers = useOptionalMatrixDrivers();
const { participation, rtcSession, client, roomId } = props;
if (participation !== null && drivers === null)
throw new Error(
"A call over the matrix-rtc crate needs the Matrix drivers to be provided",
);
if (
participation === null &&
(rtcSession === undefined || client === undefined)
)
throw new Error(
"A call needs either a participation (the crate) or a matrix-js-sdk client and session",
);
useEffect(() => {
rootLogger.info("START CALL VIEW SCOPE");
const scope = new ObservableScope();
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams;
const options = {
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
// We merely sample the current mode here, so the user would need to
// manually rejoin to switch to a different one.
matrixRTCMode: matrixRTCModeSetting.value$.value,
windowSize$: scope.behavior(observeElementSize$(rootElement)),
};
const vm = createJsClientCallViewModel$(
scope,
props.rtcSession,
props.matrixRoom,
mediaDevices,
props.muteStates,
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
// We merely sample the current mode here, so the user would need to
// manually rejoin to switch to a different one.
matrixRTCMode: matrixRTCModeSetting.value$.value,
windowSize$: scope.behavior(observeElementSize$(rootElement)),
},
reactionsReader.raisedHands$,
reactionsReader.reactions$,
scope.behavior(trackProcessorState$),
);
let vm: CallViewModel;
if (participation !== null && drivers !== null) {
rootLogger.info(
`Call view model implementation: ${CallViewModelImplementation.MatrixRtc}`,
);
const reactionsReader = new ParticipationReactionsReader(
scope,
participation,
drivers.clientDriver,
);
vm = createCallViewModel$(
scope,
participation,
drivers.clientDriver,
mediaDevices,
props.muteStates,
options,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
scope.behavior(trackProcessorState$),
);
} else if (rtcSession !== undefined) {
rootLogger.info(
`Call view model implementation: ${CallViewModelImplementation.MatrixJsSdk}`,
);
const reactionsReader = new ReactionsReader(scope, rtcSession);
vm = createJsClientCallViewModel$(
scope,
rtcSession,
rtcSession.room,
mediaDevices,
props.muteStates,
options,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
scope.behavior(trackProcessorState$),
);
} else {
return; // unreachable: checked above
}
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
vm.join();
setVm(vm);
@@ -163,8 +222,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
scope.end();
};
}, [
props.rtcSession,
props.matrixRoom,
rtcSession,
client,
props.muteStates,
props.e2eeSystem,
props.onLeft,
@@ -172,10 +231,39 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
hostBridge,
mediaDevices,
trackProcessorState$,
props.client,
rootElement,
participation,
drivers,
]);
// Who we are, as the tiles, hands and reactions are keyed. Both drivers
// and client name the same identity; the drivers are always there.
const ownIdentifier =
drivers !== null
? `${drivers.clientDriver.userId}:${drivers.clientDriver.deviceId}`
: `${client?.getUserId()}:${client?.getDeviceId()}`;
// Reactions relate to our current membership event, wherever that lives.
const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession);
const ownParticipationMembership = useBehavior(
participation?.ownMembership$ ?? NO_OWN_MEMBERSHIP,
);
const ownMembershipEventId =
participation !== null
? ownParticipationMembership?.member.eventId
: jsSdkMemberships.find(
(m) =>
m.userId === client?.getUserId() &&
m.deviceId === client?.getDeviceId(),
)?.eventId;
const reactionsTimeline = useMemo(
() =>
participation === null && client !== undefined
? jsSdkReactionsTimeline(client, roomId)
: drivers!.clientDriver,
[participation, drivers, client, roomId],
);
useEffect(() => {
if (vm === null) return;
@@ -185,7 +273,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
vm,
props.muteStates,
mediaDevices,
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
ownIdentifier,
{ showControls: urlParams.showControls, header: urlParams.header },
);
setFooterVm(footerVm);
@@ -195,15 +283,15 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
scope.end();
};
}, [
props.rtcSession,
props.matrixRoom,
rtcSession,
client,
props.muteStates,
props.e2eeSystem,
props.onLeft,
urlParams,
mediaDevices,
trackProcessorState$,
props.client,
ownIdentifier,
vm,
]);
@@ -212,7 +300,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
if (developerSettingsVm === null) return null;
return (
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<ReactionsSenderProvider
vm={vm}
ownIdentifier={ownIdentifier}
ownMembershipEventId={ownMembershipEventId}
timeline={reactionsTimeline}
>
<InCallView
{...props}
vm={vm}
@@ -224,13 +317,15 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
export interface InCallViewProps {
client: MatrixClient;
/** The matrix-js-sdk client, when that implementation carries the call. */
client?: MatrixClient;
vm: CallViewModel;
footerVm: ViewModel<FooterSnapshot>;
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
/** The matrix-js-sdk session, when that implementation carries the call. */
rtcSession?: MatrixRTCSession;
roomId: string;
muteStates: MuteStates;
onShareClick: (() => void) | null;
}
@@ -241,7 +336,7 @@ export const InCallView: FC<InCallViewProps> = ({
footerVm,
developerSettingsVm,
matrixInfo,
matrixRoom,
roomId,
muteStates,
onShareClick,
}) => {
@@ -634,9 +729,7 @@ export const InCallView: FC<InCallViewProps> = ({
}
};
const rageshakeRequestModalProps = useRageshakeRequestModal(
matrixRoom.roomId,
);
const rageshakeRequestModalProps = useRageshakeRequestModal(roomId);
useAppBarSecondaryButton(
<SettingsIconButton
@@ -692,7 +785,7 @@ export const InCallView: FC<InCallViewProps> = ({
<RageshakeRequestModal {...rageshakeRequestModalProps} />
<SettingsModal
client={client}
roomId={matrixRoom.roomId}
roomId={roomId}
open={settingsOpen}
onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab}
+8 -6
View File
@@ -7,25 +7,27 @@ Please see LICENSE in the repository root for full details.
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { type Room } from "matrix-js-sdk";
import { axe } from "vitest-axe";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { InviteModal } from "./InviteModal";
import { E2eeType } from "../e2ee/e2eeType";
// Used by copy-to-clipboard
window.prompt = (): null => null;
test("InviteModal is accessible", async () => {
const user = userEvent.setup();
const room = {
roomId: "!a:example.org",
name: "Mission Control",
} as unknown as Room;
const onDismiss = vi.fn();
const { container } = render(
<InviteModal room={room} open={true} onDismiss={onDismiss} />,
<InviteModal
roomId="!a:example.org"
roomName="Mission Control"
e2eeSystem={{ kind: E2eeType.NONE }}
open={true}
onDismiss={onDismiss}
/>,
{ wrapper: BrowserRouter },
);
+13 -7
View File
@@ -13,7 +13,6 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { type Room } from "matrix-js-sdk";
import { Button, Text } from "@vector-im/compound-web";
import {
LinkIcon,
@@ -25,22 +24,29 @@ import { Modal } from "../Modal";
import { getAbsoluteRoomUrl } from "../utils/matrix";
import styles from "./InviteModal.module.css";
import { Toast } from "../Toast";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { QrCode } from "../QrCode";
interface Props {
room: Room;
roomId: string;
roomName: string;
e2eeSystem: EncryptionSystem;
open: boolean;
onDismiss: () => void;
}
export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
export const InviteModal: FC<Props> = ({
roomId,
roomName,
e2eeSystem,
open,
onDismiss,
}) => {
const { t } = useTranslation();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const url = useMemo(
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
[e2eeSystem, room.name, room.roomId],
() => getAbsoluteRoomUrl(roomId, e2eeSystem, roomName),
[e2eeSystem, roomName, roomId],
);
const [toastOpen, setToastOpen] = useState(false);
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
+9 -10
View File
@@ -53,7 +53,8 @@ import { type ViewModel } from "../state/ViewModel";
import { useAppBarPrimaryButtonIconKind } from "../AppBar";
interface Props {
client: MatrixClient;
/** The matrix-js-sdk client, for what the developer settings still read from it. */
client?: MatrixClient;
matrixInfo: MatrixInfo;
muteStates: MuteStates;
onEnter: () => void;
@@ -258,15 +259,13 @@ export const LobbyView: FC<Props> = ({
</CallFooter>
)}
</div>
{client && (
<SettingsModal
client={client}
open={settingsModalOpen}
onDismiss={closeSettings}
tab={settingsTab}
onTabChange={setSettingsTab}
/>
)}
<SettingsModal
client={client}
open={settingsModalOpen}
onDismiss={closeSettings}
tab={settingsTab}
onTabChange={setSettingsTab}
/>
</>
);
};
+31 -2
View File
@@ -6,7 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useEffect, useState, type ReactNode, useRef } from "react";
import {
type ComponentProps,
type FC,
useEffect,
useState,
type ReactNode,
useRef,
} from "react";
import { type MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { Trans, useTranslation } from "react-i18next";
@@ -16,6 +23,10 @@ import { useClientLegacy } from "../ClientContext";
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView";
import { CallView } from "./CallView";
import { type MatrixClient } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { MatrixDriverProvider } from "../driver/MatrixDriverContext";
import { useJsSdkDrivers } from "../driver/jsSdk/useJsSdkDrivers";
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { HomePage } from "../home/HomePage";
@@ -102,7 +113,7 @@ export const RoomPage: FC = (): ReactNode => {
switch (groupCallState.kind) {
case "loaded":
return (
<CallView
<LoadedCall
client={client!}
rtcSession={groupCallState.rtcSession}
isPasswordlessUser={passwordlessUser}
@@ -190,3 +201,21 @@ export const RoomPage: FC = (): ReactNode => {
if (!roomIdOrAlias) return <HomePage />;
return groupCallView();
};
/**
* The call, with the matrix-js-sdk drivers over this page's client provided
* for the Rust implementation (see `CallViewModelImplementation`).
*/
const LoadedCall: FC<
ComponentProps<typeof CallView> & {
client: MatrixClient;
rtcSession: MatrixRTCSession;
}
> = (props) => {
const drivers = useJsSdkDrivers(props.client, props.rtcSession.room);
return (
<MatrixDriverProvider value={drivers}>
<CallView {...props} />
</MatrixDriverProvider>
);
};
-18
View File
@@ -1,18 +0,0 @@
/*
Copyright 2022-2024 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 { useCallback } from "react";
import type { JoinRule, Room } from "matrix-js-sdk";
import { useRoomState } from "./useRoomState";
export function useJoinRule(room: Room): JoinRule {
return useRoomState(
room,
useCallback((state) => state.getJoinRule(), []),
);
}
-18
View File
@@ -1,18 +0,0 @@
/*
Copyright 2022-2024 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 { useCallback } from "react";
import { type Room } from "matrix-js-sdk";
import { useRoomState } from "./useRoomState";
export function useRoomAvatar(room: Room): string | null {
return useRoomState(
room,
useCallback(() => room.getMxcAvatarUrl(), [room]),
);
}
+64
View File
@@ -0,0 +1,64 @@
/*
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 { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { type ReactNode } from "react";
import { MatrixDriverProvider } from "../driver/MatrixDriverContext";
import { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import { MockRtcMatrixDriver } from "../driver/MockRtcMatrixDriver";
import { useOwnProfile } from "../profile/useOwnProfile";
import { useRoomInfo } from "./useRoomInfo";
function providing(clientDriver: MockElementCallMatrixClientDriver) {
return ({ children }: { children: ReactNode }): ReactNode => (
<MatrixDriverProvider
value={{ rtcDriver: new MockRtcMatrixDriver(), clientDriver }}
>
{children}
</MatrixDriverProvider>
);
}
describe("useRoomInfo", () => {
it("reads the room from the client driver and follows its changes", () => {
const clientDriver = new MockElementCallMatrixClientDriver({
roomInfo: { name: "Mission Control", joinRule: "invite" },
});
const { result } = renderHook(() => useRoomInfo(), {
wrapper: providing(clientDriver),
});
expect(result.current).toMatchObject({
name: "Mission Control",
joinRule: "invite",
});
act(() => clientDriver.setRoomInfo({ name: "Launch Control" }));
expect(result.current.name).toBe("Launch Control");
});
it("throws without drivers", () => {
expect(() => renderHook(() => useRoomInfo())).toThrow(/No Matrix drivers/);
});
});
describe("useOwnProfile", () => {
it("reads our own profile and follows its changes", () => {
const clientDriver = new MockElementCallMatrixClientDriver({
ownProfile: { displayName: "Me", avatarUrl: "mxc://x/me" },
});
const { result } = renderHook(() => useOwnProfile(), {
wrapper: providing(clientDriver),
});
expect(result.current).toEqual({
displayName: "Me",
avatarUrl: "mxc://x/me",
});
act(() => clientDriver.setOwnProfile({ displayName: "Myself" }));
expect(result.current.displayName).toBe("Myself");
});
});
+36
View File
@@ -0,0 +1,36 @@
/*
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 { useEffect, useState } from "react";
import {
type RoomDriver,
type RoomInfo,
} from "../driver/ElementCallMatrixClientDriver";
import { useMatrixDrivers } from "../driver/MatrixDriverContext";
/**
* The room's name, alias, avatar, join rule, encryption and whether we may
* open its slot, from the given room driver, kept current.
*/
export function useRoomInfoFrom(room: RoomDriver): RoomInfo {
const [info, setInfo] = useState(() => room.getRoomInfo());
useEffect(() => {
setInfo(room.getRoomInfo());
return room.subscribeRoomInfo(setInfo);
}, [room]);
return info;
}
/**
* {@link useRoomInfoFrom} for the call's room: the client driver the host
* provided. Replaces `useRoomName`, `useRoomAvatar` and `useJoinRule` under
* `CallView`.
*/
export function useRoomInfo(): RoomInfo {
return useRoomInfoFrom(useMatrixDrivers().clientDriver);
}
-37
View File
@@ -1,37 +0,0 @@
/*
Copyright 2022-2024 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 { useCallback } from "react";
import {
type RoomState,
RoomStateEvent,
type Room,
RoomEvent,
} from "matrix-js-sdk";
import { useTypedEventEmitterState } from "../useEvents";
/**
* A React hook for values computed from room state.
* @param room The room.
* @param f A mapping from the current room state to the computed value.
* @returns The computed value.
*/
export function useRoomState<T>(room: Room, f: (state: RoomState) => T): T {
// TODO: matrix-js-sdk says that Room.currentState is deprecated, but it's not
// clear how to reactively track the current state of the room without it
const currentState = useTypedEventEmitterState(
room,
RoomEvent.CurrentStateUpdated,
useCallback(() => room.currentState, [room]),
);
return useTypedEventEmitterState(
currentState,
RoomStateEvent.Update,
useCallback(() => f(currentState), [f, currentState]),
);
}
+143 -19
View File
@@ -45,6 +45,7 @@ import {
muteAllAudio as muteAllAudioSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting,
callViewModelImplementation as callViewModelImplementationSetting,
customLivekitUrl as customLivekitUrlSetting,
advancedScreenShare as advancedScreenShareSetting,
screenShareResolution as screenShareResolutionSetting,
@@ -62,12 +63,17 @@ import {
type VideoCodec,
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
} from "./settings";
import { MatrixRTCMode } from "../config/ConfigOptions";
import {
CallViewModelImplementation,
MatrixRTCMode,
} from "../config/ConfigOptions";
import styles from "./DeveloperSettingsTab.module.css";
import settingsStyles from "./SettingsModal.module.css";
import { Slider } from "../Slider";
import { useUrlParams } from "../UrlParams";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
import { useOptionalMatrixDrivers } from "../driver/MatrixDriverContext";
import { ELEMENT_CALL_SLOT_ID } from "../state/rtc/slot";
import { useBehavior } from "../useBehavior";
import { type ViewModel } from "../state/ViewModel.ts";
@@ -103,7 +109,8 @@ const KeyRotationStatus: FC<{ info: KeyRotationInfo }> = ({ info }) => (
);
interface Props {
client: MatrixClient;
/** The matrix-js-sdk client, when the host has one; the drivers otherwise. */
client?: MatrixClient;
roomId?: string;
livekitRooms?: {
room: LivekitRoom;
@@ -129,17 +136,40 @@ export const DeveloperSettingsTab: FC<Props> = ({
debugTileLayoutSetting,
);
const drivers = useOptionalMatrixDrivers();
const [stickyEventsSupported, setStickyEventsSupported] = useState(false);
useEffect(() => {
client
.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS)
const probe =
client !== undefined
? client.doesServerSupportUnstableFeature(
UNSTABLE_MSC4354_STICKY_EVENTS,
)
: drivers !== null
? drivers.clientDriver
.getCapabilities()
.then((capabilities) => capabilities.stickyEvents)
: Promise.resolve(false);
probe
.then((result) => {
setStickyEventsSupported(result);
})
.catch((ex) => {
logger.warn("Failed to check if sticky events are supported", ex);
});
}, [client]);
}, [client, drivers]);
// What the host knows about itself, for the facts below.
const [diagnostics, setDiagnostics] = useState<Record<string, string>>({});
useEffect(() => {
drivers?.clientDriver
.getDiagnostics?.()
.then(setDiagnostics)
.catch((ex) =>
logger.warn("Could not read the driver's diagnostics", ex),
);
}, [drivers]);
const ownUserId = client?.getUserId() ?? drivers?.clientDriver.userId ?? null;
const ownDeviceId =
client?.getDeviceId() ?? drivers?.clientDriver.deviceId ?? null;
const [matrixRTCMode, setMatrixRTCMode] = useSetting(matrixRTCModeSetting);
const matrixRTCModeRadioGroup = useId();
@@ -155,6 +185,20 @@ export const DeveloperSettingsTab: FC<Props> = ({
const matrixRTCModeForced = configMatrixRTCMode !== undefined;
const effectiveMatrixRTCMode = configMatrixRTCMode ?? matrixRTCMode;
const [implementation, setImplementation] = useSetting(
callViewModelImplementationSetting,
);
const implementationRadioGroup = useId();
const onImplementationChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setImplementation(e.target.value as CallViewModelImplementation);
},
[setImplementation],
);
const configImplementation = Config.get().call_view_model_implementation;
const implementationForced = configImplementation !== undefined;
const effectiveImplementation = configImplementation ?? implementation;
const [showConnectionStats, setShowConnectionStats] = useSetting(
showConnectionStatsSetting,
);
@@ -387,17 +431,20 @@ export const DeveloperSettingsTab: FC<Props> = ({
</p>
<p>
{t("developer_mode.crypto_version", {
version: client.getCrypto()?.getVersion() || "unknown",
version:
client?.getCrypto()?.getVersion() ||
diagnostics.crypto_version ||
"unknown",
})}
</p>
<p>
{t("developer_mode.matrix_id", {
id: client.getUserId() || "unknown",
id: ownUserId || "unknown",
})}
</p>
<p>
{t("developer_mode.device_id", {
id: client.getDeviceId() || "unknown",
id: ownDeviceId || "unknown",
})}
</p>
{keyRotation !== null && <KeyRotationStatus info={keyRotation} />}
@@ -512,25 +559,45 @@ export const DeveloperSettingsTab: FC<Props> = ({
}
try {
const userId = client.getUserId();
const deviceId = client.getDeviceId();
if (userId === null || deviceId === null) {
if (ownUserId === null || ownDeviceId === null) {
throw new Error("Invalid user or device ID");
}
await getSFUConfigWithOpenID(
client,
{ userId, deviceId, memberId: "" },
customLivekitUrlTextBuffer,
roomId,
);
if (client !== undefined) {
await getSFUConfigWithOpenID(
client,
{ userId: ownUserId, deviceId: ownDeviceId, memberId: "" },
customLivekitUrlTextBuffer,
roomId,
);
} else if (drivers !== null) {
// Ask the service for a token the way the crate would.
await drivers.rtcDriver.getLivekitToken({
url: customLivekitUrlTextBuffer,
roomId,
slotId: ELEMENT_CALL_SLOT_ID,
memberJson: JSON.stringify({
id: "",
claimed_user_id: ownUserId,
claimed_device_id: ownDeviceId,
}),
legacySfuGet: false,
});
}
setCustomLivekitUrlUpdateError(null);
setCustomLivekitUrl(customLivekitUrlTextBuffer);
} catch {
setCustomLivekitUrlUpdateError("invalid URL (did not update)");
}
},
[customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId],
[
customLivekitUrlTextBuffer,
setCustomLivekitUrl,
client,
drivers,
roomId,
ownUserId,
ownDeviceId,
],
)}
value={customLivekitUrlTextBuffer ?? ""}
onChange={useCallback(
@@ -590,6 +657,63 @@ export const DeveloperSettingsTab: FC<Props> = ({
</HelpMessage>
</InlineField>
</Form>
<Separator />
<Heading as="h3" type="body" weight="semibold" size="lg">
{t("developer_mode.callViewModelImplementation.title")}
</Heading>
{implementationForced && (
<p>{t("developer_mode.callViewModelImplementation.forced")}</p>
)}
<Form>
<InlineField
name={implementationRadioGroup}
control={
<RadioControl
checked={
effectiveImplementation ===
CallViewModelImplementation.MatrixJsSdk
}
value={CallViewModelImplementation.MatrixJsSdk}
disabled={implementationForced}
onChange={onImplementationChange}
/>
}
>
<Label>
{t(
"developer_mode.callViewModelImplementation.matrix_js_sdk.label",
)}
</Label>
<HelpMessage>
{t(
"developer_mode.callViewModelImplementation.matrix_js_sdk.description",
)}
</HelpMessage>
</InlineField>
<InlineField
name={implementationRadioGroup}
control={
<RadioControl
checked={
effectiveImplementation ===
CallViewModelImplementation.MatrixRtc
}
value={CallViewModelImplementation.MatrixRtc}
disabled={implementationForced}
onChange={onImplementationChange}
/>
}
>
<Label>
{t("developer_mode.callViewModelImplementation.matrix_rtc.label")}
</Label>
<HelpMessage>
{t(
"developer_mode.callViewModelImplementation.matrix_rtc.description",
)}
</HelpMessage>
</InlineField>
</Form>
{livekitRooms?.map((livekitRoom) => (
<div className={styles.livekit_room_box}>
<h4>
+41 -16
View File
@@ -5,23 +5,48 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useEffect, useMemo, useRef } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import { useProfile } from "../profile/useProfile";
import { useOwnProfile } from "../profile/useOwnProfile";
import { useMatrixDrivers } from "../driver/MatrixDriverContext";
import { FieldRow, InputField, ErrorMessage } from "../input/Input";
import { AvatarInputField } from "../input/AvatarInputField";
import styles from "./ProfileSettingsTab.module.css";
interface Props {
client: MatrixClient;
}
export const ProfileSettingsTab: FC<Props> = ({ client }) => {
export const ProfileSettingsTab: FC = () => {
const { t } = useTranslation();
const { error, displayName, avatarUrl, saveProfile } = useProfile(client);
const userId = useMemo(() => client.getUserId(), [client]);
const { clientDriver } = useMatrixDrivers();
const { displayName, avatarUrl } = useOwnProfile();
const userId = clientDriver.userId;
const [error, setError] = useState<Error | undefined>(undefined);
// The driver may offer no way to edit the profile; then it is shown as is.
const canEdit =
clientDriver.setDisplayName !== undefined &&
clientDriver.setAvatar !== undefined;
const saveProfile = useCallback(
async ({
displayName,
avatar,
removeAvatar,
}: {
displayName: string;
avatar: Blob | undefined;
removeAvatar: boolean;
}): Promise<void> => {
try {
await clientDriver.setDisplayName?.(displayName);
if (removeAvatar) await clientDriver.setAvatar?.(null);
else if (avatar) await clientDriver.setAvatar?.(avatar);
setError(undefined);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
throw e;
}
},
[clientDriver],
);
const formRef = useRef<HTMLFormElement | null>(null);
@@ -58,9 +83,7 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
saveProfile({
displayName,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
avatar: avatar && avatarSize > 0 ? avatar : undefined,
avatar: avatar instanceof Blob && avatarSize > 0 ? avatar : undefined,
removeAvatar: removeAvatar.current && (!avatar || avatarSize === 0),
}).catch((e) => {
logger.error("Failed to save profile", e);
@@ -72,15 +95,16 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
return (
<form onChange={onFormChange} ref={formRef} className={styles.content}>
<FieldRow className={styles.avatarFieldRow}>
{userId && displayName && (
{displayName && (
<AvatarInputField
id="avatar"
name="avatar"
label={t("common.avatar")}
avatarUrl={avatarUrl}
avatarUrl={avatarUrl ?? undefined}
userId={userId}
displayName={displayName}
onRemoveAvatar={onRemoveAvatar}
disabled={!canEdit}
/>
)}
</FieldRow>
@@ -91,7 +115,7 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
label={t("common.username")}
type="text"
disabled
value={client.getUserId()!}
value={userId}
/>
</FieldRow>
<FieldRow>
@@ -101,9 +125,10 @@ export const ProfileSettingsTab: FC<Props> = ({ client }) => {
label={t("common.display_name")}
type="text"
required
disabled={!canEdit}
autoComplete="off"
placeholder={t("common.display_name")}
defaultValue={displayName}
defaultValue={displayName ?? undefined}
data-testid="profile_displayname"
/>
</FieldRow>
+3 -2
View File
@@ -54,7 +54,8 @@ interface Props {
onDismiss: () => void;
tab: SettingsTab;
onTabChange: (tab: SettingsTab) => void;
client: MatrixClient;
/** The matrix-js-sdk client, for what the developer tab still reads from it. */
client?: MatrixClient;
roomId?: string;
livekitRooms?: {
room: LivekitRoom;
@@ -211,7 +212,7 @@ export const SettingsModal: FC<Props> = ({
const profileTab: Tab<SettingsTab> = {
key: "profile",
name: t("common.profile"),
content: <ProfileSettingsTab client={client} />,
content: <ProfileSettingsTab />,
};
const feedbackTab: Tab<SettingsTab> = {
@@ -30,7 +30,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _inputField_1bd8c0"
>
<input
aria-describedby="_r_1_"
aria-describedby="_r_2_"
id="duplicateTiles"
min="0"
type="number"
@@ -50,7 +50,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_2_"
aria-describedby="_r_3_"
id="debugTileLayout"
type="checkbox"
/>
@@ -87,7 +87,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_3_"
aria-describedby="_r_4_"
id="showConnectionStats"
type="checkbox"
/>
@@ -124,7 +124,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_4_"
aria-describedby="_r_5_"
id="muteAllAudio"
type="checkbox"
/>
@@ -162,7 +162,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_5_"
aria-describedby="_r_6_"
id="alwaysShowIphoneEarpiece"
type="checkbox"
/>
@@ -199,7 +199,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_6_"
aria-describedby="_r_7_"
id="enableLivekitExtendedLogs"
type="checkbox"
/>
@@ -237,7 +237,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_7_"
for="radix-_r_8_"
>
Custom Livekit-url
</label>
@@ -245,9 +245,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_controls_17lij_8"
>
<input
aria-describedby="radix-_r_8_"
aria-describedby="radix-_r_9_"
class="_control_d83jn_10"
id="radix-_r_7_"
id="radix-_r_8_"
name="input"
title=""
value=""
@@ -255,7 +255,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_8_"
id="radix-_r_9_"
>
Currently, no overwrite is set. Url from config is used.
</span>
@@ -285,10 +285,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_a_ radix-_r_c_"
aria-describedby="radix-_r_b_ radix-_r_d_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_9_"
id="radix-_r_a_"
name="_r_0_"
title=""
type="radio"
@@ -304,13 +304,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_9_"
for="radix-_r_a_"
>
Compatibility: state events & multi SFU
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_a_"
id="radix-_r_b_"
>
Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)
</span>
@@ -326,9 +326,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_a_ radix-_r_c_"
aria-describedby="radix-_r_b_ radix-_r_d_"
class="_input_1ug7n_18"
id="radix-_r_b_"
id="radix-_r_c_"
name="_r_0_"
title=""
type="radio"
@@ -344,19 +344,115 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<label
class="_label_1o4d9_60"
for="radix-_r_b_"
for="radix-_r_c_"
>
Matrix 2.0: sticky events & multi SFU
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_c_"
id="radix-_r_d_"
>
Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later
</span>
</div>
</div>
</form>
<div
class="_separator_13qwf_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<h3
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74"
>
Call implementation
</h3>
<form
class="_root_1o4d9_17"
>
<div
class="_inline-field_1o4d9_33"
>
<div
class="_inline-field-control_1o4d9_45"
>
<div
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_f_ radix-_r_h_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_e_"
name="_r_1_"
title=""
type="radio"
value="matrix-js-sdk"
/>
<div
class="_ui_1ug7n_19"
/>
</div>
</div>
<div
class="_inline-field-body_1o4d9_39"
>
<label
class="_label_1o4d9_60"
for="radix-_r_e_"
>
matrix-js-sdk
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_f_"
>
The MatrixRTC session of matrix-js-sdk carries the call, as before
</span>
</div>
</div>
<div
class="_inline-field_1o4d9_33"
>
<div
class="_inline-field-control_1o4d9_45"
>
<div
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_f_ radix-_r_h_"
class="_input_1ug7n_18"
id="radix-_r_g_"
name="_r_1_"
title=""
type="radio"
value="matrix-rtc"
/>
<div
class="_ui_1ug7n_19"
/>
</div>
</div>
<div
class="_inline-field-body_1o4d9_39"
>
<label
class="_label_1o4d9_60"
for="radix-_r_g_"
>
matrix-rtc (Rust)
</label>
<span
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_h_"
>
The Rust matrix-rtc crate carries the call through the host's drivers
</span>
</div>
</div>
</form>
<div
class="_livekit_room_box_2ddec4"
>
@@ -459,7 +555,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_i_"
aria-describedby="_r_n_"
id="cameraToggle"
type="checkbox"
/>
@@ -489,7 +585,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</label>
<p
class="_description_1bd8c0"
id="_r_i_"
id="_r_n_"
>
Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.
</p>
@@ -511,7 +607,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_j_"
aria-describedby="_r_o_"
id="screenShareToggle"
type="checkbox"
/>
@@ -541,7 +637,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</label>
<p
class="_description_1bd8c0"
id="_r_j_"
id="_r_o_"
>
Configure resolution, framerate, bitrate, and codec for screen sharing
</p>
@@ -566,7 +662,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_k_"
aria-describedby="_r_p_"
checked=""
id="echoCancellation"
type="checkbox"
@@ -604,7 +700,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_l_"
aria-describedby="_r_q_"
checked=""
id="noiseSuppression"
type="checkbox"
@@ -642,7 +738,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_m_"
aria-describedby="_r_r_"
checked=""
id="autoGainControl"
type="checkbox"
+16 -1
View File
@@ -11,7 +11,10 @@ import { BehaviorSubject } from "rxjs";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { type Behavior } from "../state/Behavior";
import { useBehavior } from "../useBehavior";
import { MatrixRTCMode } from "../config/ConfigOptions";
import {
CallViewModelImplementation,
MatrixRTCMode,
} from "../config/ConfigOptions";
export class Setting<T> {
public constructor(
@@ -153,6 +156,18 @@ export const matrixRTCMode = new Setting<MatrixRTCMode>(
MatrixRTCMode.Compatibility,
);
/**
* Which implementation carries the call (see
* {@link CallViewModelImplementation}). Sampled when the call view mounts;
* `config.json` may pin it. Defaults to matrix-js-sdk until the crate path
* has passed its gate.
*/
export const callViewModelImplementation =
new Setting<CallViewModelImplementation>(
"call-view-model-implementation",
CallViewModelImplementation.MatrixJsSdk,
);
export const customLivekitUrl = new Setting<string | null>(
"custom-livekit-url",
null,
+51 -11
View File
@@ -23,6 +23,8 @@ import { deepCompare } from "matrix-js-sdk/lib/utils";
import { advancedCamera as advancedCameraSetting } from "./settings";
import { advancedScreenShare as advancedScreenShareSetting } from "./settings";
import { DEFAULT_CONFIG } from "../config/ConfigOptions";
import { effectiveCallViewModelImplementation } from "../state/rtc/implementation";
import { useOptionalMatrixDrivers } from "../driver/MatrixDriverContext";
const gzip = async (text: string): Promise<Blob> => {
// pako is relatively large (200KB), so we only import it when needed
const { gzip: pakoGzip } = await import("pako");
@@ -143,6 +145,7 @@ export function useSubmitRageshake(
available: boolean;
} {
const { client } = useClient();
const drivers = useOptionalMatrixDrivers();
const [{ sending, sent, error }, setState] = useState<{
sending: boolean;
@@ -202,6 +205,25 @@ export function useSubmitRageshake(
body.append("installed_pwa", "false");
body.append("touch_input", touchInput);
body.append("call_backend", "livekit");
body.append(
"call_view_model_implementation",
effectiveCallViewModelImplementation(),
);
// What the host's drivers know, and the crate's own view of the
// call when it carries it.
if (drivers?.clientDriver.getDiagnostics) {
try {
for (const [key, value] of Object.entries(
await drivers.clientDriver.getDiagnostics(),
))
body.append(`driver_${key}`, value);
} catch (e) {
logger.warn("Could not collect the driver's diagnostics", e);
}
}
const participation = window.matrixRtc?.participation;
if (participation)
body.append("matrix_rtc_snapshot", participation.debugSnapshot());
body.append("hostname", window.location.hostname);
if (client) {
@@ -316,7 +338,7 @@ export function useSubmitRageshake(
logger.error(error);
}
},
[client, sending, injectedGetRageshakeSubmitUrl],
[client, drivers, sending, injectedGetRageshakeSubmitUrl],
);
return {
@@ -333,19 +355,24 @@ export function useRageshakeRequest(): (
rageshakeRequestId: string,
) => void {
const { client } = useClient();
const drivers = useOptionalMatrixDrivers();
const sendRageshakeRequest = useCallback(
(roomId: string, rageshakeRequestId: string) => {
client!
// @ts-expect-error - org.matrix.rageshake_request is not part of `keyof TimelineEvents` but it is okay to sent a custom event.
.sendEvent(roomId, "org.matrix.rageshake_request", {
request_id: rageshakeRequestId,
})
.catch((e) => {
logger.error("Failed to send org.matrix.rageshake_request event", e);
});
const content = { request_id: rageshakeRequestId };
const sent: Promise<unknown> =
drivers !== null
? drivers.clientDriver.sendRoomEvent(
"org.matrix.rageshake_request",
content,
)
: // @ts-expect-error - org.matrix.rageshake_request is not part of `keyof TimelineEvents` but it is okay to sent a custom event.
client!.sendEvent(roomId, "org.matrix.rageshake_request", content);
sent.catch((e: unknown) => {
logger.error("Failed to send org.matrix.rageshake_request event", e);
});
},
[client],
[client, drivers],
);
return sendRageshakeRequest;
}
@@ -356,9 +383,22 @@ export function useRageshakeRequestModal(
const [open, setOpen] = useState(false);
const onDismiss = useCallback(() => setOpen(false), [setOpen]);
const { client } = useClient();
const drivers = useOptionalMatrixDrivers();
const [rageshakeRequestId, setRageshakeRequestId] = useState<string>();
useEffect(() => {
if (drivers !== null) {
const { clientDriver } = drivers;
return clientDriver.subscribeTimeline((event) => {
if (
event.type === "org.matrix.rageshake_request" &&
event.sender !== clientDriver.userId
) {
setRageshakeRequestId(event.content.request_id as string);
setOpen(true);
}
});
}
if (!client) return;
const onEvent = (event: MatrixEvent): void => {
@@ -379,7 +419,7 @@ export function useRageshakeRequestModal(
return (): void => {
client.removeListener(ClientEvent.Event, onEvent);
};
}, [setOpen, roomId, client]);
}, [setOpen, roomId, client, drivers]);
return {
rageshakeRequestId: rageshakeRequestId ?? "",
@@ -24,7 +24,11 @@ export interface ParticipationRoster {
ownMemberId$: Behavior<string | null>;
}
/** The crate's membership as a tile sees it. */
/**
* The crate's membership as a tile sees it. `deviceId` falls back to the
* member id so that `${userId}:${deviceId}` is the member's media id
* (`memberMediaId` in `src/state/rtc/mediaId.ts`).
*/
export function callMemberOf(membership: FfiMembership): CallMember {
const { member } = membership;
return {
+5
View File
@@ -306,6 +306,11 @@ describe("CallParticipation", () => {
callParticipation.keyMap$.value.some((k) => k.memberId === peer.memberId),
);
expect(changes.some((k) => k.memberId === peer.memberId)).toBe(true);
// Counted for the ended-call analytics: theirs received, ours sent.
expect(callParticipation.mediaKeyStatistics()).toMatchObject({
received: 1,
sent: changes.filter((k) => k.memberId !== peer.memberId).length,
});
driver.peerLeaves(peer);
// the crate keeps a LeftWithKeys entry; the behavior does not
await waitFor(
+37
View File
@@ -67,6 +67,14 @@ export interface SlotPolicy {
/** How long to wait for the seed, and for our own slot event to echo back. */
const SLOT_WAIT_MS = 15_000;
/** Media keys sent and received over a participation; see {@link CallParticipation.mediaKeyStatistics}. */
export interface MediaKeyStatistics {
sent: number;
received: number;
/** Sum of the ages of the received keys on arrival, in ms. */
receivedTotalAge: number;
}
export interface CallParticipationOptions {
/**
* One manager per `(room, slot)`; Element Call has one slot per room.
@@ -203,6 +211,7 @@ export class CallParticipation {
this.manager.setKeyMapListener({
onKeyMapChange: (keyMap, change) => {
if (this.ended) return;
this.countKey(change);
this.keyMapSubject$.next(keyMap);
this.keyChangesSubject$.next(change);
},
@@ -301,6 +310,34 @@ export class CallParticipation {
}
}
private readonly keyStatistics: MediaKeyStatistics = {
sent: 0,
received: 0,
receivedTotalAge: 0,
};
private countKey(change: FfiMediaKey): void {
if (change.memberId === this.manager.ownMemberId()) {
this.keyStatistics.sent++;
} else {
this.keyStatistics.received++;
this.keyStatistics.receivedTotalAge += Math.max(
0,
Date.now() - Number(change.creationTsMs),
);
}
}
/**
* How many media keys this participation has sent and received so far,
* and how old the received ones were on arrival (summed), for the
* ended-call analytics. Counted from the crate's key changes: one of ours
* per index we rotate to, one of theirs per key that reaches us.
*/
public mediaKeyStatistics(): MediaKeyStatistics {
return { ...this.keyStatistics };
}
/** Leave the session. A no-op when not joined. */
public async leave(code?: string, reason?: string): Promise<void> {
if (this.ended) return;
+31
View File
@@ -0,0 +1,31 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { Config } from "../../config/Config";
import { CallViewModelImplementation } from "../../config/ConfigOptions";
import { callViewModelImplementation } from "../../settings/settings";
/**
* Which implementation carries the call right now: the deployment's pin in
* `config.json` when there is one, else the user's Developer Settings
* choice. Read when the call view mounts; a change takes effect on the next
* call.
*/
export function effectiveCallViewModelImplementation(): CallViewModelImplementation {
return (
Config.get().call_view_model_implementation ??
callViewModelImplementation.value$.value
);
}
/** Whether the Rust `matrix-rtc` crate carries the call. */
export function usesMatrixRtc(): boolean {
return (
effectiveCallViewModelImplementation() ===
CallViewModelImplementation.MatrixRtc
);
}
+18
View File
@@ -0,0 +1,18 @@
/*
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 FfiMember } from "../../matrix-rtc-sdk";
/**
* The id the call view keys a member's tiles, hands and reactions by:
* `${userId}:${deviceId}`, as it has always been. The crate does not always
* know a member's device (an unencrypted room, a pre-sticky event); the
* member id stands in then, which keeps the id unique.
*/
export function memberMediaId(member: FfiMember): string {
return `${member.userId}:${member.deviceId ?? member.memberId}`;
}
@@ -0,0 +1,56 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { MatrixRTCMode } from "../../config/ConfigOptions";
import { type MatrixDrivers } from "../../driver/MatrixDriverContext";
import { MockElementCallMatrixClientDriver } from "../../driver/MockElementCallMatrixClientDriver";
import { MockRtcMatrixDriver } from "../../driver/MockRtcMatrixDriver";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import { participationConfig } from "./joinParams";
import { useCallParticipation } from "./useCallParticipation";
const config = participationConfig({
mode: MatrixRTCMode.Matrix_2_0,
manageMediaKeys: false,
session: {
delayed_leave: { delay_ms: 15_000 },
delegated_delayed_leave: { delay_ms: 3_600_000 },
network_error_retry_ms: 1000,
},
});
describe("useCallParticipation", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
it("creates a participation for the drivers and ends it on unmount", async () => {
const drivers: MatrixDrivers = {
rtcDriver: new MockRtcMatrixDriver(),
clientDriver: new MockElementCallMatrixClientDriver(),
};
const { result, rerender, unmount } = renderHook(
({ drivers }) => useCallParticipation(drivers, config),
{ initialProps: { drivers: null as MatrixDrivers | null } },
);
// Nothing without drivers (matrix-js-sdk carries the call).
expect(result.current).toBeNull();
rerender({ drivers });
await waitFor(() => expect(result.current).not.toBeNull());
const participation = result.current!;
// It seeds the session from the driver right away.
await waitFor(() => expect(participation.session$.value.seeded).toBe(true));
unmount();
// Ended: the manager is gone, its diagnostics say nothing.
expect(participation.debugSnapshot()).toBe("{}");
});
});
+86
View File
@@ -0,0 +1,86 @@
/*
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 { useEffect, useState } from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixDrivers } from "../../driver/MatrixDriverContext";
import {
type FfiParticipationConfig,
initMatrixRtcSdk,
} from "../../matrix-rtc-sdk";
import { ObservableScope } from "../ObservableScope";
import {
CallParticipation,
type CallParticipationOptions,
} from "./CallParticipation";
/**
* A {@link CallParticipation} for the mounted call view: created when the
* drivers or the configuration change, ended (leaving the session if still
* joined) when the view unmounts. Null for the first render, like the other
* scoped objects the views own.
*
* Owned by `CallView` rather than the in-call view so that the lobby already
* sees the roster and a rejoin does not rebuild the session seed.
*
* The SDK's wasm is loaded here, on first use, rather than at app start: the
* matrix-js-sdk implementation must not pay for the crate's ~6.5 MB
* (oxidation plan §5.15), and the load is idempotent, so a rejoin or a later
* call finds it already booted. A failed load is thrown from the hook so the
* nearest error boundary shows it instead of the call silently never
* starting.
*/
export function useCallParticipation(
drivers: MatrixDrivers | null,
config: FfiParticipationConfig | null,
options: Omit<CallParticipationOptions, "config"> = {},
): CallParticipation | null {
const [participation, setParticipation] = useState<CallParticipation | null>(
null,
);
const [loadError, setLoadError] = useState<unknown>(null);
const { transportFallbackUrl, slotId } = options;
useEffect(() => {
if (drivers === null || config === null) return;
const scope = new ObservableScope();
const { clientDriver, rtcDriver } = drivers;
// Whether the view is still mounted with these inputs by the time the
// SDK is ready; if not, nothing is created and the scope is already over.
let ended = false;
initMatrixRtcSdk().then(
() => {
if (ended) return;
logger.info(
`[Lifecycle] Creating the call participation for ${clientDriver.roomId} (compat ${config.compat})`,
);
const participation = new CallParticipation(
scope,
rtcDriver,
clientDriver.roomId,
clientDriver.userId,
clientDriver.deviceId,
{ config, transportFallbackUrl, slotId },
);
setParticipation(participation);
},
(e: unknown) => {
if (ended) return;
logger.error("[Lifecycle] Failed to load the MatrixRTC SDK", e);
setLoadError(e);
},
);
return (): void => {
ended = true;
logger.info("[Lifecycle] Ending the call participation");
setParticipation(null);
scope.end();
};
}, [drivers, config, transportFallbackUrl, slotId]);
if (loadError !== null) throw loadError;
return participation;
}
+26 -20
View File
@@ -12,7 +12,6 @@ import {
import { test, expect } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject } from "rxjs";
import { GridTile } from "./GridTile";
@@ -25,7 +24,10 @@ import {
mockMediaDevices,
} from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import {
ReactionsSenderProvider,
type ReactionsTimeline,
} from "../reactions/useReactionsSender";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import { constant } from "../state/Behavior";
import {
@@ -39,21 +41,10 @@ global.IntersectionObserver = class MockIntersectionObserver {
public disconnect(): void {}
} as unknown as typeof IntersectionObserver;
const fakeRtcSession = {
on: () => {},
off: () => {},
room: {
on: () => {},
off: () => {},
client: {
getUserId: () => null,
getDeviceId: () => null,
on: () => {},
off: () => {},
},
},
memberships: [],
} as unknown as MatrixRTCSession;
const fakeTimeline: ReactionsTimeline = {
sendRoomEvent: async () => Promise.resolve({ eventId: "$reaction" }),
redactEvent: async () => Promise.resolve(),
};
const callVm = {
reactions$: constant({}),
@@ -75,7 +66,12 @@ test("GridTile displays remote media", async () => {
);
const { container } = render(
<ReactionsSenderProvider vm={callVm} rtcSession={fakeRtcSession}>
<ReactionsSenderProvider
vm={callVm}
ownIdentifier="@local:example.org:LOCALDEV"
ownMembershipEventId={undefined}
timeline={fakeTimeline}
>
<GridTile
vm={new GridTileViewModel(constant(vm))}
onOpenProfile={() => {}}
@@ -109,7 +105,12 @@ test("GridTile displays local media", async () => {
);
const { container } = render(
<ReactionsSenderProvider vm={callVm} rtcSession={fakeRtcSession}>
<ReactionsSenderProvider
vm={callVm}
ownIdentifier="@local:example.org:LOCALDEV"
ownMembershipEventId={undefined}
timeline={fakeTimeline}
>
<GridTile
vm={new GridTileViewModel(constant(vm))}
onOpenProfile={() => {}}
@@ -142,7 +143,12 @@ test("GridTile displays ringing media", async () => {
});
const { container } = render(
<ReactionsSenderProvider vm={callVm} rtcSession={fakeRtcSession}>
<ReactionsSenderProvider
vm={callVm}
ownIdentifier="@local:example.org:LOCALDEV"
ownMembershipEventId={undefined}
timeline={fakeTimeline}
>
<GridTile
vm={new GridTileViewModel(constant(vm))}
onOpenProfile={() => {}}
-20
View File
@@ -35,26 +35,6 @@ export function useEventTarget<T extends Event>(
}, [target, eventType, listener, options]);
}
/**
* Shortcut for registering a listener on a TypedEventEmitter.
*/
export function useTypedEventEmitter<
Events extends string,
Arguments extends ListenerMap<Events>,
T extends Events,
>(
emitter: TypedEventEmitter<Events, Arguments>,
eventType: T,
listener: Listener<Events, Arguments, T>,
): void {
useEffect(() => {
emitter.on(eventType, listener);
return (): void => {
emitter.off(eventType, listener);
};
}, [emitter, eventType, listener]);
}
/**
* Reactively tracks a value which is recalculated whenever the provided event
* emitter emits an event. This is useful for bridging state from matrix-js-sdk
+21 -8
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023, 2024 New Vector Ltd.
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
@@ -10,16 +10,29 @@ import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { useCallback } from "react";
import { useCallback, useSyncExternalStore } from "react";
import { useTypedEventEmitterState } from "./useEvents";
const NONE: CallMembership[] = [];
/**
* The memberships of a matrix-js-sdk session, kept current; none without a
* session (the Rust crate carries the call then).
*/
export function useMatrixRTCSessionMemberships(
rtcSession: MatrixRTCSession,
rtcSession: MatrixRTCSession | undefined,
): CallMembership[] {
return useTypedEventEmitterState(
rtcSession,
MatrixRTCSessionEvent.MembershipsChanged,
useCallback(() => rtcSession.memberships, [rtcSession]),
const subscribe = useCallback(
(onChange: () => void) => {
if (rtcSession === undefined) return (): void => {};
rtcSession.on(MatrixRTCSessionEvent.MembershipsChanged, onChange);
return (): void => {
rtcSession.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
};
},
[rtcSession],
);
return useSyncExternalStore(
subscribe,
useCallback(() => rtcSession?.memberships ?? NONE, [rtcSession]),
);
}