mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-26 00:12:27 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c497cc6a8 | ||
|
|
8ebb0b5a90 | ||
|
|
f56c74adf9 | ||
|
|
3c88d217f2 | ||
|
|
967a06ba39 | ||
|
|
71593f1a24 | ||
|
|
5d38a13c46 | ||
|
|
426acd222e |
@@ -34,16 +34,16 @@ You can find the latest development version continuously deployed to
|
||||
## ✨ Key Features
|
||||
|
||||
✅ **Decentralized & Federated** – No central authority; works across Matrix
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
|
||||
in Matrix clients as a widget, or (experimentally) mount it as a React component
|
||||
inside your own application.
|
||||
✅ **WebRTC-based** – No additional software required.
|
||||
inside your own application.
|
||||
✅ **WebRTC-based** – No additional software required.
|
||||
✅ **Scalable with LiveKit** – Supports large meetings via SFU
|
||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||
✅ **Raise Hand** – Participants can signal when they want to speak, helping to
|
||||
organize the flow of the meeting.
|
||||
organize the flow of the meeting.
|
||||
✅ **Emoji Reactions** – Users can react with emojis 👍️ 🎉 👏 🤘, adding
|
||||
engagement and interactivity to the conversation.
|
||||
|
||||
@@ -309,6 +309,23 @@ root. The host imports the component from
|
||||
`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves
|
||||
them external.
|
||||
|
||||
The component is large, and a host will usually load it lazily, only once a
|
||||
call is shown. Everything a host needs in order to talk about a call before
|
||||
then is also exported
|
||||
from `@element-hq/element-call-component/api` (a small entry point that does not
|
||||
load the component).
|
||||
|
||||
- props and the types they are made of
|
||||
- the handle
|
||||
- the host bridge
|
||||
- `UserIntent`
|
||||
- `HeaderStyle`
|
||||
- `BackgroundStyle` enums
|
||||
- `configurationForIntent`, the defaults each intent implies
|
||||
Import from it wherever a value such as
|
||||
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
|
||||
entry point re-exports all of it too.
|
||||
|
||||
### Backend
|
||||
|
||||
A docker compose file `docker-compose-dev.yml` is provided to start the
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* What a host needs in order to talk about Element Call, without Element Call:
|
||||
* the component's props and the types they are made of, the handle and the
|
||||
* host bridge, the intents a call can be started with and the configuration
|
||||
* each implies.
|
||||
*
|
||||
* This is an entry point of its own, `@element-hq/element-call-component/api`,
|
||||
* so that a host can import it on a path that must stay light — a call model
|
||||
* that needs `BackgroundStyle.Solid` as a value, say — and load the component
|
||||
* itself, and everything it brings, only when a call is shown. The main entry
|
||||
* point re-exports all of it, so a host that does not care may import
|
||||
* everything from there.
|
||||
*
|
||||
* Nothing in here may reach anything that is not a type or a constant: the
|
||||
* point of this module is what it does not import.
|
||||
*/
|
||||
|
||||
import type { Ref } from "react";
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import type { UrlProperties } from "../src/UrlParams";
|
||||
import type { ElementCallHandle, ElementCallHostBridge } from "./host";
|
||||
import {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
} from "../src/UrlConfiguration";
|
||||
|
||||
// How the host and Element Call talk to each other, and what they say
|
||||
export type { ElementCallHandle, ElementCallHostBridge } from "./host";
|
||||
export type { DeviceMuteRequest, DeviceMuteState } from "../src/HostBridge";
|
||||
export type { JoinCallData } from "../src/widget";
|
||||
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||
// below, which is per call
|
||||
export type { ConfigOptions } from "../src/config/ConfigOptions";
|
||||
// The values that appear in ElementCallConfiguration and in the intent, and
|
||||
// what each intent means by default
|
||||
export {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
};
|
||||
|
||||
/**
|
||||
* How Element Call should behave. Everything is optional; anything left out
|
||||
* takes the default that {@link ElementCallProps.intent} implies.
|
||||
*
|
||||
* This is the behaviour a widget can be configured with through its URL, plus
|
||||
* the one fact about the call a host has a say in here, the background. The
|
||||
* rest of what a widget's URL carries — who the user is, how to reach the
|
||||
* homeserver, where to report analytics, the shared secret of a room that is
|
||||
* encrypted with one — a component host supplies by other routes, or not at
|
||||
* all; and what can change while the call is running, the theme and the
|
||||
* language, is a prop of its own.
|
||||
*/
|
||||
export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||
Partial<Pick<UrlProperties, "background">>;
|
||||
|
||||
export interface ElementCallProps {
|
||||
/**
|
||||
* The client to place the call with. Element Call does not authenticate
|
||||
* anyone or manage a session of its own; this one is the host's.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
/**
|
||||
* What the user asked for — whether they started the call or joined one that
|
||||
* was already running, and whether it is a call in a group or a DM. Element
|
||||
* Call decides what each of those means: whether to show the lobby first,
|
||||
* whether to ring, and so on.
|
||||
*
|
||||
* Defaults to joining an existing group call, which is the most conservative
|
||||
* reading, but a host that knows which button the user pressed should say so.
|
||||
*/
|
||||
intent?: UserIntent;
|
||||
/**
|
||||
* How Element Call should behave, overriding whatever {@link intent} implies.
|
||||
* A host that finds itself setting a lot of these probably wants a different
|
||||
* intent instead.
|
||||
*
|
||||
* Compared by value, so it is fine to write this inline; only a change to
|
||||
* what it says restarts anything.
|
||||
*/
|
||||
config?: ElementCallConfiguration;
|
||||
/**
|
||||
* What Element Call tells the host while the call is running: that the user
|
||||
* has joined or hung up, that it would like to be kept on screen, and so on.
|
||||
* Without one, Element Call assumes nobody is listening.
|
||||
*/
|
||||
hostBridge?: ElementCallHostBridge;
|
||||
/**
|
||||
* What the host tells Element Call: to hang up, to mute, to join. Available
|
||||
* once the component has rendered.
|
||||
*/
|
||||
ref?: Ref<ElementCallHandle>;
|
||||
/**
|
||||
* The theme to show Element Call in, `light` or `dark`. Left out, Element
|
||||
* Call picks. Changes take effect at once, and cost nothing else.
|
||||
*/
|
||||
theme?: string;
|
||||
/**
|
||||
* The language to show Element Call in, as a BCP 47 tag: one of the
|
||||
* `supportedLanguages` the main entry point exports, or something that falls
|
||||
* back to one (`de-AT` to `de`). Left out, the browser's language is used.
|
||||
*
|
||||
* Translations are one thing shared by every Element Call on the page, so
|
||||
* the most recently set language wins for all of them.
|
||||
*/
|
||||
language?: string;
|
||||
}
|
||||
+6
-96
@@ -36,14 +36,12 @@ import {
|
||||
type FC,
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
@@ -62,10 +60,8 @@ import { RootElementProvider, useRootElement } from "../src/RootElementContext";
|
||||
import {
|
||||
configurationForIntent,
|
||||
componentProperties,
|
||||
type UrlConfiguration,
|
||||
type UrlParams,
|
||||
UrlParamsProvider,
|
||||
type UrlProperties,
|
||||
UserIntent,
|
||||
useUrlParams,
|
||||
} from "../src/UrlParams";
|
||||
@@ -79,102 +75,16 @@ import { i18n } from "../src/utils/i18n";
|
||||
import { useTheme } from "../src/useTheme";
|
||||
import { useStableValue } from "../src/useStableValue";
|
||||
import styles from "./ElementCall.module.css";
|
||||
import {
|
||||
type ElementCallHandle,
|
||||
type ElementCallHostBridge,
|
||||
useComponentHostBridge,
|
||||
} from "./host";
|
||||
import { useComponentHostBridge } from "./host";
|
||||
import { type ElementCallProps } from "./api";
|
||||
import { supportedLanguages, translationsBackend } from "./localization";
|
||||
|
||||
// The languages Element Call can be shown in
|
||||
export { supportedLanguages } from "./localization";
|
||||
|
||||
// How the host and Element Call talk to each other, and what they say
|
||||
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
|
||||
export {
|
||||
type DeviceMuteRequest,
|
||||
type DeviceMuteState,
|
||||
} from "../src/HostBridge";
|
||||
export { type JoinCallData } from "../src/widget";
|
||||
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||
// above, which is per call
|
||||
export { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
// The values that appear in ElementCallConfiguration and in the intent
|
||||
export {
|
||||
BackgroundStyle,
|
||||
HeaderStyle,
|
||||
UserIntent,
|
||||
type UrlConfiguration,
|
||||
} from "../src/UrlParams";
|
||||
|
||||
/**
|
||||
* How Element Call should behave. Everything is optional; anything left out
|
||||
* takes the default that {@link ElementCallProps.intent} implies.
|
||||
*
|
||||
* This is the behaviour a widget can be configured with through its URL, plus
|
||||
* the one fact about the call a host has a say in here, the background. The
|
||||
* rest of what a widget's URL carries — who the user is, how to reach the
|
||||
* homeserver, where to report analytics, the shared secret of a room that is
|
||||
* encrypted with one — a component host supplies by other routes, or not at
|
||||
* all; and what can change while the call is running, the theme and the
|
||||
* language, is a prop of its own.
|
||||
*/
|
||||
export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||
Partial<Pick<UrlProperties, "background">>;
|
||||
|
||||
export interface ElementCallProps {
|
||||
/**
|
||||
* The client to place the call with. Element Call does not authenticate
|
||||
* anyone or manage a session of its own; this one is the host's.
|
||||
*/
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
/**
|
||||
* What the user asked for — whether they started the call or joined one that
|
||||
* was already running, and whether it is a call in a group or a DM. Element
|
||||
* Call decides what each of those means: whether to show the lobby first,
|
||||
* whether to ring, and so on.
|
||||
*
|
||||
* Defaults to joining an existing group call, which is the most conservative
|
||||
* reading, but a host that knows which button the user pressed should say so.
|
||||
*/
|
||||
intent?: UserIntent;
|
||||
/**
|
||||
* How Element Call should behave, overriding whatever {@link intent} implies.
|
||||
* A host that finds itself setting a lot of these probably wants a different
|
||||
* intent instead.
|
||||
*
|
||||
* Compared by value, so it is fine to write this inline; only a change to
|
||||
* what it says restarts anything.
|
||||
*/
|
||||
config?: ElementCallConfiguration;
|
||||
/**
|
||||
* What Element Call tells the host while the call is running: that the user
|
||||
* has joined or hung up, that it would like to be kept on screen, and so on.
|
||||
* Without one, Element Call assumes nobody is listening.
|
||||
*/
|
||||
hostBridge?: ElementCallHostBridge;
|
||||
/**
|
||||
* What the host tells Element Call: to hang up, to mute, to join. Available
|
||||
* once the component has rendered.
|
||||
*/
|
||||
ref?: Ref<ElementCallHandle>;
|
||||
/**
|
||||
* The theme to show Element Call in, `light` or `dark`. Left out, Element
|
||||
* Call picks. Changes take effect at once, and cost nothing else.
|
||||
*/
|
||||
theme?: string;
|
||||
/**
|
||||
* The language to show Element Call in, as a BCP 47 tag: one of
|
||||
* {@link supportedLanguages}, or something that falls back to one (`de-AT`
|
||||
* to `de`). Left out, the browser's language is used.
|
||||
*
|
||||
* Translations are one thing shared by every Element Call on the page, so
|
||||
* the most recently set language wins for all of them.
|
||||
*/
|
||||
language?: string;
|
||||
}
|
||||
// Everything a host needs to talk about Element Call — the props, the handle,
|
||||
// the host bridge, the intents and what each means — also available without
|
||||
// the component itself from `@element-hq/element-call-component/api`
|
||||
export * from "./api";
|
||||
|
||||
/**
|
||||
* Prepares the things Element Call needs before it can be shown: translations,
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"types": "./dist/types/component/index.d.ts",
|
||||
"default": "./dist/element-call.js"
|
||||
},
|
||||
"./api": {
|
||||
"types": "./dist/types/component/api.d.ts",
|
||||
"default": "./dist/api.js"
|
||||
},
|
||||
"./style.css": "./dist/element-call.css"
|
||||
},
|
||||
"sideEffects": [
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
"rootDir": "..",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
// The entry point, plus the ambient declarations (CSS modules, `?react` SVGs,
|
||||
// `import.meta.env`, …) that the sources it reaches rely on.
|
||||
"include": ["./index.tsx", "../src/@types/*.d.ts"],
|
||||
// The entry points, plus the ambient declarations (CSS modules, `?react` SVGs,
|
||||
// `import.meta.env`, …) that the sources they reach rely on.
|
||||
"include": ["./index.tsx", "./api.ts", "../src/@types/*.d.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
|
||||
|
||||
@@ -76,4 +76,7 @@ Code that builds in only one is a bug.
|
||||
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
|
||||
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
|
||||
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
|
||||
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
|
||||
`matrix-js-sdk` / `livekit-client` subpath the externals list omits. Two entry
|
||||
points: `index.tsx` (the component) and `api.ts` (types, enums and
|
||||
`configurationForIntent`, for a host that must not load the component). `api.ts`
|
||||
may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`.
|
||||
|
||||
@@ -273,7 +273,6 @@
|
||||
"mute_for_me": "Mute for me",
|
||||
"muted_for_me": "Muted for me",
|
||||
"screen_share_volume": "Screen share volume",
|
||||
"unknown_participant": "Unknown participant",
|
||||
"volume": "Volume",
|
||||
"waiting_for_media": "Waiting for media..."
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||
"build": "pnpm build:full",
|
||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
|
||||
"build:full:production": "pnpm build:full",
|
||||
"build:full:development": "pnpm build:full --mode development",
|
||||
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
|
||||
|
||||
@@ -109,10 +109,15 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
|
||||
|
||||
// Accept the invite
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await expect(
|
||||
ewPage2.getByRole("option", { name: "Welcome Room" }),
|
||||
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
|
||||
).toBeVisible();
|
||||
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
|
||||
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
|
||||
await ewPage2.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
ewPage2
|
||||
@@ -152,8 +157,12 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
).toBeVisible();
|
||||
|
||||
// Accept the DM invite from brooks
|
||||
// This how playwright record selects the DM invite in the room list
|
||||
await ewPage2.getByRole("button", { name: "Open room" }).click();
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
|
||||
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type JSHandle,
|
||||
type Page,
|
||||
type FrameLocator,
|
||||
type Locator,
|
||||
} from "@playwright/test";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
@@ -203,6 +204,16 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
public static async expandAllSections(page: Page): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Expand all sections" })
|
||||
.click({ timeout: 2000 });
|
||||
} catch {
|
||||
// Already expanded or button not present
|
||||
}
|
||||
}
|
||||
|
||||
public static async createRoom(
|
||||
name: string,
|
||||
page: Page,
|
||||
@@ -253,9 +264,9 @@ export class TestHelpers {
|
||||
roomName: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: roomName }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Accept" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -350,6 +361,17 @@ export class TestHelpers {
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a room in the room list by its name.
|
||||
*
|
||||
* Matches on the aria-label prefix because the item's role differs between
|
||||
* the flat room list (`option`) and the sectioned room list (`button`), and
|
||||
* the label may carry a suffix such as " invitation.".
|
||||
*/
|
||||
public static roomListItem(page: Page, roomName: string): Locator {
|
||||
return page.locator(`[aria-label^="Open room ${roomName}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
@@ -359,7 +381,8 @@ export class TestHelpers {
|
||||
page: Page,
|
||||
roomName: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click();
|
||||
}
|
||||
|
||||
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How Element Call can be told to behave, and what each intent means.
|
||||
*
|
||||
* Kept apart from {@link UrlParams} on purpose: everything here is constants,
|
||||
* types and one function of them, so that a host of the component can import
|
||||
* these — the enums it needs as values, the defaults an intent implies — without
|
||||
* loading Element Call itself. UrlParams, which reads them from a URL, needs
|
||||
* the router, the config and the rest of the app; this module must not.
|
||||
* `component/api.ts` is built from it as an entry point of its own.
|
||||
*/
|
||||
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { platform } from "./Platform";
|
||||
|
||||
export enum UserIntent {
|
||||
StartNewCall = "start_call",
|
||||
JoinExistingCall = "join_existing",
|
||||
StartNewCallVoice = "start_call_voice",
|
||||
JoinExistingCallVoice = "join_existing_voice",
|
||||
StartNewCallDM = "start_call_dm",
|
||||
StartNewCallDMVoice = "start_call_dm_voice",
|
||||
JoinExistingCallDM = "join_existing_dm",
|
||||
JoinExistingCallDMVoice = "join_existing_dm_voice",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
export enum HeaderStyle {
|
||||
None = "none",
|
||||
Standard = "standard",
|
||||
AppBar = "app_bar",
|
||||
}
|
||||
|
||||
export enum BackgroundStyle {
|
||||
Solid = "solid",
|
||||
Gradient = "gradient",
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration for the app, which can be set via URL parameters.
|
||||
* Those property are different to the UrlProperties, since they are all optional
|
||||
* and configure the behavior of the app. Their value is the same if EC is used in
|
||||
* the same context but with different accounts/users.
|
||||
*
|
||||
* Their defaults can be controlled by the `intent` property.
|
||||
*/
|
||||
export interface UrlConfiguration {
|
||||
/**
|
||||
* Whether the app should keep the user confined to the current call/room.
|
||||
*/
|
||||
confineToRoom: boolean;
|
||||
/**
|
||||
* Whether the app should pause before joining the call until it sees an
|
||||
* io.element.join widget action, allowing it to be preloaded.
|
||||
*/
|
||||
preload: boolean;
|
||||
/**
|
||||
* The style of headers to show. "standard" is the default arrangement, "none"
|
||||
* hides the header entirely, and "app_bar" produces a header with a back
|
||||
* button like you might see in mobile apps. The callback for the back button
|
||||
* is window.controls.onBackButtonPressed.
|
||||
*/
|
||||
header: HeaderStyle;
|
||||
/**
|
||||
* Whether the controls should be shown. For screen recording no controls can be desired.
|
||||
*/
|
||||
showControls: boolean;
|
||||
/**
|
||||
* Whether to hide the screen-sharing button.
|
||||
*/
|
||||
hideScreensharing: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app is allowed to use fallback STUN servers for ICE in case the
|
||||
* user's homeserver doesn't provide any.
|
||||
*/
|
||||
allowIceFallback: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app should use per participant keys for E2EE.
|
||||
*/
|
||||
perParticipantE2EE: boolean;
|
||||
/**
|
||||
* Whether the global JS controls for audio output devices should be enabled,
|
||||
* allowing the list of output devices to be controlled by the app hosting
|
||||
* Element Call.
|
||||
*/
|
||||
controlledAudioDevices: boolean;
|
||||
/**
|
||||
* Setting this flag skips the lobby and brings you in the call directly.
|
||||
* In the widget this can be combined with preload to pass the device settings
|
||||
* with the join widget action.
|
||||
*/
|
||||
skipLobby: boolean;
|
||||
/**
|
||||
* Setting this flag makes element call show the lobby after leaving a call.
|
||||
* This is useful for video rooms.
|
||||
*/
|
||||
returnToLobby: boolean;
|
||||
/**
|
||||
* Whether and what type of notification EC should send, when the user joins the call.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* Whether the app should automatically leave the call when there
|
||||
* is no one left in the call.
|
||||
* This is one part to make the call matrixRTC session behave like a telephone call.
|
||||
*/
|
||||
autoLeaveWhenOthersLeft: boolean;
|
||||
|
||||
/**
|
||||
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
|
||||
* This is a no-op if not combined with sendNotificationType.
|
||||
*
|
||||
* This entails:
|
||||
* - show ui that it is awaiting an answer
|
||||
* - play a sound that indicates that it is awaiting an answer
|
||||
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
|
||||
*/
|
||||
waitForCallPickup: boolean;
|
||||
|
||||
/**
|
||||
* Whether to enable echo cancellation for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
echoCancellation?: boolean;
|
||||
/**
|
||||
* Whether to enable noise suppression for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
noiseSuppression?: boolean;
|
||||
|
||||
callIntent?: RTCCallIntent;
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||
// clearer what each flag means, and helps us avoid coupling Element Call's
|
||||
// behavior to the needs of specific consumers.
|
||||
|
||||
/**
|
||||
* The configuration implied by what the user meant to do — if they pressed a
|
||||
* Start Call button this would be `start_call`, and if they pressed Join Call,
|
||||
* `join_existing`.
|
||||
*
|
||||
* These are platform-specific defaults, so that a host can start a call by
|
||||
* saying what the user asked for rather than by setting every parameter itself,
|
||||
* and so that what each intent means is Element Call's decision, made in one
|
||||
* place. A host that wants something else states it alongside the intent.
|
||||
*
|
||||
* {@link UserIntent.Unknown} means no intent was stated, and gives the
|
||||
* standalone app's defaults: Element Call owns the whole page, so it offers the
|
||||
* way out of the room that a hosted call must not.
|
||||
*/
|
||||
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
// Only constants and `platform` here, so that this depends on nothing but
|
||||
// the intent.
|
||||
let preset: UrlConfiguration = {
|
||||
confineToRoom: true,
|
||||
preload: false,
|
||||
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: true,
|
||||
perParticipantE2EE: true,
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: true,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification",
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
preset = {
|
||||
confineToRoom: false,
|
||||
preload: false,
|
||||
header: HeaderStyle.Standard,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: false,
|
||||
perParticipantE2EE: false,
|
||||
controlledAudioDevices: false,
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: undefined,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
+17
-222
@@ -9,47 +9,36 @@ Please see LICENSE in the repository root for full details.
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { pickBy } from "lodash-es";
|
||||
|
||||
import { Config } from "./config/Config";
|
||||
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "./e2ee/e2eeType";
|
||||
import { platform } from "./Platform";
|
||||
import {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
} from "./UrlConfiguration";
|
||||
import { redact } from "./utils/redact";
|
||||
|
||||
// Moved to their own module, so that a host can import them without the rest
|
||||
// of this one; re-exported here as they always were
|
||||
export {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
HeaderStyle,
|
||||
type UrlConfiguration,
|
||||
UserIntent,
|
||||
} from "./UrlConfiguration";
|
||||
|
||||
interface RoomIdentifier {
|
||||
roomAlias: string | null;
|
||||
roomId: string | null;
|
||||
viaServers: string[];
|
||||
}
|
||||
|
||||
export enum UserIntent {
|
||||
StartNewCall = "start_call",
|
||||
JoinExistingCall = "join_existing",
|
||||
StartNewCallVoice = "start_call_voice",
|
||||
JoinExistingCallVoice = "join_existing_voice",
|
||||
StartNewCallDM = "start_call_dm",
|
||||
StartNewCallDMVoice = "start_call_dm_voice",
|
||||
JoinExistingCallDM = "join_existing_dm",
|
||||
JoinExistingCallDMVoice = "join_existing_dm_voice",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
export enum HeaderStyle {
|
||||
None = "none",
|
||||
Standard = "standard",
|
||||
AppBar = "app_bar",
|
||||
}
|
||||
|
||||
export enum BackgroundStyle {
|
||||
Solid = "solid",
|
||||
Gradient = "gradient",
|
||||
}
|
||||
|
||||
/**
|
||||
* The UrlProperties are used to pass required data to the widget.
|
||||
* Those are different in different rooms, users, devices. They do not configure the behavior of the
|
||||
@@ -166,109 +155,6 @@ export interface UrlProperties {
|
||||
*/
|
||||
background: BackgroundStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration for the app, which can be set via URL parameters.
|
||||
* Those property are different to the UrlProperties, since they are all optional
|
||||
* and configure the behavior of the app. Their value is the same if EC is used in
|
||||
* the same context but with different accounts/users.
|
||||
*
|
||||
* Their defaults can be controlled by the `intent` property.
|
||||
*/
|
||||
export interface UrlConfiguration {
|
||||
/**
|
||||
* Whether the app should keep the user confined to the current call/room.
|
||||
*/
|
||||
confineToRoom: boolean;
|
||||
/**
|
||||
* Whether the app should pause before joining the call until it sees an
|
||||
* io.element.join widget action, allowing it to be preloaded.
|
||||
*/
|
||||
preload: boolean;
|
||||
/**
|
||||
* The style of headers to show. "standard" is the default arrangement, "none"
|
||||
* hides the header entirely, and "app_bar" produces a header with a back
|
||||
* button like you might see in mobile apps. The callback for the back button
|
||||
* is window.controls.onBackButtonPressed.
|
||||
*/
|
||||
header: HeaderStyle;
|
||||
/**
|
||||
* Whether the controls should be shown. For screen recording no controls can be desired.
|
||||
*/
|
||||
showControls: boolean;
|
||||
/**
|
||||
* Whether to hide the screen-sharing button.
|
||||
*/
|
||||
hideScreensharing: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app is allowed to use fallback STUN servers for ICE in case the
|
||||
* user's homeserver doesn't provide any.
|
||||
*/
|
||||
allowIceFallback: boolean;
|
||||
|
||||
/**
|
||||
* Whether the app should use per participant keys for E2EE.
|
||||
*/
|
||||
perParticipantE2EE: boolean;
|
||||
/**
|
||||
* Whether the global JS controls for audio output devices should be enabled,
|
||||
* allowing the list of output devices to be controlled by the app hosting
|
||||
* Element Call.
|
||||
*/
|
||||
controlledAudioDevices: boolean;
|
||||
/**
|
||||
* Setting this flag skips the lobby and brings you in the call directly.
|
||||
* In the widget this can be combined with preload to pass the device settings
|
||||
* with the join widget action.
|
||||
*/
|
||||
skipLobby: boolean;
|
||||
/**
|
||||
* Setting this flag makes element call show the lobby after leaving a call.
|
||||
* This is useful for video rooms.
|
||||
*/
|
||||
returnToLobby: boolean;
|
||||
/**
|
||||
* Whether and what type of notification EC should send, when the user joins the call.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/**
|
||||
* Whether the app should automatically leave the call when there
|
||||
* is no one left in the call.
|
||||
* This is one part to make the call matrixRTC session behave like a telephone call.
|
||||
*/
|
||||
autoLeaveWhenOthersLeft: boolean;
|
||||
|
||||
/**
|
||||
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
|
||||
* This is a no-op if not combined with sendNotificationType.
|
||||
*
|
||||
* This entails:
|
||||
* - show ui that it is awaiting an answer
|
||||
* - play a sound that indicates that it is awaiting an answer
|
||||
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
|
||||
*/
|
||||
waitForCallPickup: boolean;
|
||||
|
||||
/**
|
||||
* Whether to enable echo cancellation for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
echoCancellation?: boolean;
|
||||
/**
|
||||
* Whether to enable noise suppression for audio capture.
|
||||
* Defaults to true.
|
||||
*/
|
||||
noiseSuppression?: boolean;
|
||||
|
||||
callIntent?: RTCCallIntent;
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||
// clearer what each flag means, and helps us avoid coupling Element Call's
|
||||
// behavior to the needs of specific consumers.
|
||||
export interface UrlParams extends UrlProperties, UrlConfiguration {}
|
||||
|
||||
class ParamParser {
|
||||
@@ -354,97 +240,6 @@ export const getUrlParams = (
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* The configuration implied by what the user meant to do — if they pressed a
|
||||
* Start Call button this would be `start_call`, and if they pressed Join Call,
|
||||
* `join_existing`.
|
||||
*
|
||||
* These are platform-specific defaults, so that a host can start a call by
|
||||
* saying what the user asked for rather than by setting every parameter itself,
|
||||
* and so that what each intent means is Element Call's decision, made in one
|
||||
* place. A host that wants something else states it alongside the intent.
|
||||
*
|
||||
* {@link UserIntent.Unknown} means no intent was stated, and gives the
|
||||
* standalone app's defaults: Element Call owns the whole page, so it offers the
|
||||
* way out of the room that a hosted call must not.
|
||||
*/
|
||||
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
// Only constants and `platform` here, so that this depends on nothing but
|
||||
// the intent.
|
||||
let preset: UrlConfiguration = {
|
||||
confineToRoom: true,
|
||||
preload: false,
|
||||
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: true,
|
||||
perParticipantE2EE: true,
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: true,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification",
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
preset = {
|
||||
confineToRoom: false,
|
||||
preload: false,
|
||||
header: HeaderStyle.Standard,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: false,
|
||||
perParticipantE2EE: false,
|
||||
controlledAudioDevices: false,
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: undefined,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link UrlProperties} for Element Call running as a component inside a
|
||||
* host application.
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as ComponentsCore from "@livekit/components-core";
|
||||
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { AudioOutput } from "./MediaDevices";
|
||||
import { audioOutput as audioOutputSetting } from "../settings/settings";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
|
||||
const BT_SPEAKER = {
|
||||
@@ -93,6 +94,8 @@ describe("AudioOutput Tests", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
testScope = new ObservableScope();
|
||||
// Device preferences persist in localStorage across tests
|
||||
audioOutputSetting.setValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -150,7 +153,9 @@ describe("AudioOutput Tests", () => {
|
||||
|
||||
expectObservable(audioOutput.selected$).toBe("abcde", {
|
||||
a: undefined,
|
||||
b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
// No "default" pseudo-device (Firefox): the virtual browser default is
|
||||
// selected until the user picks something.
|
||||
b: { id: "", virtualEarpiece: false },
|
||||
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
@@ -158,6 +163,61 @@ describe("AudioOutput Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the browser default when the selected device disappears", () => {
|
||||
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a---b", {
|
||||
a: DEVICE_LIST_B,
|
||||
// The monitor is unplugged (or a Bluetooth sink changes profile)
|
||||
b: [LAPTOP_SPEAKER],
|
||||
}),
|
||||
);
|
||||
|
||||
const audioOutput = new AudioOutput(
|
||||
behavior("a", { a: true }),
|
||||
testScope,
|
||||
);
|
||||
|
||||
schedule("--a", {
|
||||
a: () => audioOutput.select(MONITOR_SPEAKER.deviceId),
|
||||
});
|
||||
|
||||
expectObservable(audioOutput.selected$).toBe("a-b-c", {
|
||||
a: { id: "", virtualEarpiece: false },
|
||||
b: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
|
||||
// Rather than pinning some other physical device, let the browser route
|
||||
c: { id: "", virtualEarpiece: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("lists the virtual browser default first when there is no default pseudo-device", () => {
|
||||
withTestScheduler(({ behavior, cold, expectObservable }) => {
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
cold("a", { a: DEVICE_LIST_B }),
|
||||
);
|
||||
|
||||
const audioOutput = new AudioOutput(
|
||||
behavior("a", { a: true }),
|
||||
testScope,
|
||||
);
|
||||
|
||||
expectObservable(audioOutput.available$).toBe("a", {
|
||||
a: new Map([
|
||||
["", { type: "default", name: null }],
|
||||
[
|
||||
LAPTOP_SPEAKER.deviceId,
|
||||
{ type: "name", name: LAPTOP_SPEAKER.label },
|
||||
],
|
||||
[
|
||||
MONITOR_SPEAKER.deviceId,
|
||||
{ type: "name", name: MONITOR_SPEAKER.label },
|
||||
],
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Test mappings", () => {
|
||||
// In a real life setup there would be first a blanked list
|
||||
// then the real one.
|
||||
|
||||
@@ -52,10 +52,8 @@ import {
|
||||
aliceRtcMember,
|
||||
aliceUserId,
|
||||
bob,
|
||||
bobDeviceId,
|
||||
bobId,
|
||||
bobRtcMember,
|
||||
bobUserId,
|
||||
local,
|
||||
localId,
|
||||
localRtcMember,
|
||||
@@ -120,19 +118,6 @@ const daveId = `${dave.userId}:${daveRtcMember.deviceId}`;
|
||||
const bobParticipant = mockRemoteParticipant({ identity: bobId });
|
||||
const daveParticipant = mockRemoteParticipant({ identity: daveId });
|
||||
|
||||
// A LiveKit participant that no MatrixRTC membership accounts for
|
||||
const rogueParticipant = mockRemoteParticipant({ identity: "rogue" });
|
||||
const rogueId = `unknown:${exampleTransport.livekit_service_url}:rogue`;
|
||||
|
||||
const otherTransport: LivekitTransport = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.other.example.org",
|
||||
livekit_alias: "!alias:other.example.org",
|
||||
};
|
||||
const bobOnOtherFocusRtcMember = mockRtcMembership(bobUserId, bobDeviceId, {
|
||||
fociPreferred: [otherTransport],
|
||||
});
|
||||
|
||||
export interface GridLayoutSummary {
|
||||
type: "grid";
|
||||
spotlight?: string[];
|
||||
@@ -465,113 +450,6 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
});
|
||||
});
|
||||
|
||||
test("LiveKit participants without a membership get an unknown participant tile", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// A participant nobody's membership accounts for connects on frame 1 and
|
||||
// disconnects on frame 2
|
||||
const participantInputMarbles = "aba";
|
||||
// It gets a tile at the end of the grid for as long as it is connected
|
||||
const expectedLayoutMarbles = " aba";
|
||||
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: behavior(participantInputMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [aliceParticipant, bobParticipant, rogueParticipant],
|
||||
}),
|
||||
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
|
||||
},
|
||||
(vm) => {
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`, rogueId],
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("an unknown participant takes the call out of one-on-one layout", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// A participant nobody's membership accounts for connects on frame 1 and
|
||||
// disconnects on frame 2
|
||||
const participantInputMarbles = "aba";
|
||||
// The one-on-one layout would hide the unknown participant, so we must
|
||||
// fall back to the grid while they are present
|
||||
const expectedLayoutMarbles = " aba";
|
||||
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: behavior(participantInputMarbles, {
|
||||
a: [aliceParticipant],
|
||||
b: [aliceParticipant, rogueParticipant],
|
||||
}),
|
||||
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||
},
|
||||
(vm) => {
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "one-on-one-desktop",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
b: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: [`${localId}:0`, `${aliceId}:0`, rogueId],
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("a member seen on a focus other than their own is not an unknown participant", () => {
|
||||
withTestScheduler(({ expectObservable }) => {
|
||||
// Bob publishes to another focus, but also connects to ours to subscribe
|
||||
// (the mock reports the same participants on every connection). Matching
|
||||
// by identity rather than per transport keeps him from being flagged.
|
||||
const expectedLayoutMarbles = "a";
|
||||
|
||||
withCallViewModel(
|
||||
{
|
||||
remoteParticipants$: constant([aliceParticipant, bobParticipant]),
|
||||
rtcMembers$: constant([
|
||||
localRtcMember,
|
||||
aliceRtcMember,
|
||||
bobOnOtherFocusRtcMember,
|
||||
]),
|
||||
},
|
||||
(vm) => {
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("one-on-one mobile layout shows local tile when video is enabled", () => {
|
||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||
// Local participant enables their video, then disables it
|
||||
@@ -1333,18 +1211,15 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
});
|
||||
});
|
||||
|
||||
test("participants without a MatrixRTC membership are shown as unknown, without media", () => {
|
||||
test("participants must have a MatrixRTCSession to be visible", () => {
|
||||
withTestScheduler(({ behavior, expectObservable }) => {
|
||||
// iterate through a number of combinations of participants and MatrixRTC memberships
|
||||
// Bob never has an MatrixRTC membership
|
||||
const participantInputMarbles = "abcd-c";
|
||||
// Bob even tries to share his screen at the end
|
||||
const bobSharingInputMarbles = " n---yn";
|
||||
// Bob gets an unknown participant tile (MSC4143) rather than a tile of
|
||||
// his own, and his screen share never shows up. His presence also keeps
|
||||
// the call out of the one-on-one layout, which would hide him.
|
||||
const expectedLayoutMarbles = " abcd-c";
|
||||
const bobUnknownId = `unknown:${exampleTransport.livekit_service_url}:${bobId}`;
|
||||
// Bob should never be visible
|
||||
const expectedLayoutMarbles = " a-bc-b";
|
||||
|
||||
withCallViewModel(
|
||||
{
|
||||
@@ -1376,25 +1251,14 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
grid: [`${localId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: [`${localId}:0`, bobUnknownId],
|
||||
type: "one-on-one-desktop",
|
||||
pip: `${localId}:0`,
|
||||
spotlight: `${aliceId}:0`,
|
||||
},
|
||||
// Tiles keep their positions, so Bob stays ahead of the others
|
||||
c: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: [`${localId}:0`, bobUnknownId, `${aliceId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: [
|
||||
`${localId}:0`,
|
||||
bobUnknownId,
|
||||
`${aliceId}:0`,
|
||||
`${daveId}:0`,
|
||||
],
|
||||
grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -95,7 +95,6 @@ import {
|
||||
layoutShallowEquals,
|
||||
type Alignment,
|
||||
type GridLayoutMedia,
|
||||
type GridMediaViewModel,
|
||||
type Layout,
|
||||
type LayoutMedia,
|
||||
type OneOnOneDesktopLayoutMedia,
|
||||
@@ -163,10 +162,6 @@ import {
|
||||
createRingingMedia,
|
||||
type RingingMediaViewModel,
|
||||
} from "../media/RingingMediaViewModel.ts";
|
||||
import {
|
||||
createUnknownParticipantMedia,
|
||||
type UnknownParticipantMediaViewModel,
|
||||
} from "../media/UnknownParticipantMediaViewModel.ts";
|
||||
import { type GridTileViewModel } from "../TileViewModel.ts";
|
||||
|
||||
//TODO
|
||||
@@ -874,61 +869,6 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Media for LiveKit participants that cannot be mapped to any MatrixRTC
|
||||
* membership. MSC4143 asks us to surface these rather than ignore them, as
|
||||
* they can signal an eavesdropping or impersonation attack.
|
||||
*
|
||||
* Participants are matched to memberships by identity alone rather than per
|
||||
* transport, since a member legitimately shows up as a subscribe-only
|
||||
* participant on every focus other than the one they publish to.
|
||||
*/
|
||||
const unknownParticipantMedia$ = scope.behavior<
|
||||
UnknownParticipantMediaViewModel[]
|
||||
>(
|
||||
combineLatest([
|
||||
memberships$,
|
||||
connectionManager.connectionManagerData$,
|
||||
]).pipe(
|
||||
// Memberships and connections derive from the same epoch of session
|
||||
// state; comparing them across epochs would flag members whose
|
||||
// connection hasn't caught up yet.
|
||||
filter(([memberships, data]) => memberships.epoch === data.epoch),
|
||||
map(([memberships, data]) => {
|
||||
const knownIdentities = new Set(
|
||||
memberships.value.map((m) => m.rtcBackendIdentity),
|
||||
);
|
||||
return data.value.getConnections().flatMap((connection) =>
|
||||
data.value
|
||||
.getParticipantsForTransport(connection.transport)
|
||||
.filter((p) => !knownIdentities.has(p.identity))
|
||||
.map((p) => ({
|
||||
focusUrl: connection.transport.livekit_service_url,
|
||||
identity: p.identity,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
generateItems(
|
||||
"CallViewModel unknownParticipantMedia$",
|
||||
function* (unknownParticipants) {
|
||||
for (const { focusUrl, identity } of unknownParticipants)
|
||||
yield { keys: [focusUrl, identity], data: undefined };
|
||||
},
|
||||
(_scope, _data$, focusUrl, identity) => {
|
||||
logger.warn(
|
||||
`LiveKit participant ${identity} on ${focusUrl} matches no MatrixRTC membership`,
|
||||
);
|
||||
return createUnknownParticipantMedia({
|
||||
id: `unknown:${focusUrl}:${identity}`,
|
||||
rtcBackendIdentity: identity,
|
||||
focusUrl,
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const ringingMedia$ = scope.behavior<RingingMediaViewModel | null>(
|
||||
ringAttempts$.pipe(
|
||||
switchMap(({ intent, recipient, outcome$ }) =>
|
||||
@@ -1075,29 +1015,21 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const sortedUserMedia$: Observable<UserMediaViewModel[]> = userMedia$.pipe(
|
||||
switchMap((mediaItems) => {
|
||||
const bins = mediaItems.map((m) =>
|
||||
m.bin$.pipe(map((bin) => [m, bin] as const)),
|
||||
);
|
||||
// Sort the media by bin order and generate a tile for each one
|
||||
return bins.length === 0
|
||||
? of([])
|
||||
: combineLatest(bins, (...bins) =>
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const grid$ = scope.behavior<GridMediaViewModel[]>(
|
||||
combineLatest(
|
||||
[sortedUserMedia$, unknownParticipantMedia$],
|
||||
// Unknown participants have no media to sort by, so they go last
|
||||
(userMedia, unknownParticipants) => [
|
||||
...userMedia,
|
||||
...unknownParticipants,
|
||||
],
|
||||
).pipe(distinctUntilChanged(shallowArrayEquals)),
|
||||
const grid$ = scope.behavior<UserMediaViewModel[]>(
|
||||
userMedia$.pipe(
|
||||
switchMap((mediaItems) => {
|
||||
const bins = mediaItems.map((m) =>
|
||||
m.bin$.pipe(map((bin) => [m, bin] as const)),
|
||||
);
|
||||
// Sort the media by bin order and generate a tile for each one
|
||||
return bins.length === 0
|
||||
? of([])
|
||||
: combineLatest(bins, (...bins) =>
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(shallowArrayEquals),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1257,15 +1189,10 @@ export function createCallViewModel$(
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: UserMediaViewModel | RingingMediaViewModel;
|
||||
} | null> = scope.behavior(
|
||||
combineLatest([userMedia$, screenShares$, unknownParticipantMedia$]).pipe(
|
||||
switchMap(([userMedia, screenShares, unknownParticipants]) => {
|
||||
// One-on-one layout only supports 2 user media, no screen shares, and
|
||||
// must never hide an unknown participant
|
||||
if (
|
||||
userMedia.length <= 2 &&
|
||||
screenShares.length === 0 &&
|
||||
unknownParticipants.length === 0
|
||||
) {
|
||||
combineLatest([userMedia$, screenShares$]).pipe(
|
||||
switchMap(([userMedia, screenShares]) => {
|
||||
// One-on-one layout only supports 2 user media, no screen shares
|
||||
if (userMedia.length <= 2 && screenShares.length === 0) {
|
||||
const local = userMedia.find(
|
||||
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
|
||||
vm.type === "user" && vm.local,
|
||||
|
||||
@@ -257,14 +257,21 @@ export class AudioOutput implements MediaDevice<
|
||||
map((availableRaw) => {
|
||||
let available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
// Create a virtual default audio output for browsers that don't have one
|
||||
// (Firefox, Safari). Its device ID must be the empty string because
|
||||
// that's what setSinkId recognizes. It goes first so that it is the
|
||||
// fallback when no output has been explicitly chosen (or the chosen
|
||||
// one disappears), rather than pinning the first physical device
|
||||
// with setSinkId: pinned sinks are not re-routed by the browser, and
|
||||
// Firefox leaves the audio elements silent when a pinned sink goes
|
||||
// away (e.g. a Bluetooth headset switching profile when its
|
||||
// microphone is opened). We can't know which physical device the
|
||||
// browser default resolves to, so the entry carries no name.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
available = new Map<string, AudioOutputDeviceLabel>([
|
||||
["", { type: "default", name: null }],
|
||||
...available,
|
||||
]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
|
||||
if (isSafari) {
|
||||
|
||||
+12
-13
@@ -8,15 +8,12 @@ Please see LICENSE in the repository root for full details.
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
type GridTileMediaViewModel,
|
||||
GridTileViewModel,
|
||||
SpotlightTileViewModel,
|
||||
} from "./TileViewModel";
|
||||
import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel";
|
||||
import { fillGaps } from "../utils/iter";
|
||||
import { debugTileLayout } from "../settings/settings";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
|
||||
|
||||
type SpotlightBackground = "solid" | "transparent";
|
||||
|
||||
@@ -71,8 +68,10 @@ class SpotlightTileData {
|
||||
}
|
||||
|
||||
class GridTileData {
|
||||
private readonly media$: BehaviorSubject<GridTileMediaViewModel>;
|
||||
public get media(): GridTileMediaViewModel {
|
||||
private readonly media$: BehaviorSubject<
|
||||
UserMediaViewModel | RingingMediaViewModel
|
||||
>;
|
||||
public get media(): UserMediaViewModel | RingingMediaViewModel {
|
||||
return this.media$.value;
|
||||
}
|
||||
public set media(value: UserMediaViewModel) {
|
||||
@@ -81,7 +80,7 @@ class GridTileData {
|
||||
|
||||
public readonly vm: GridTileViewModel;
|
||||
|
||||
public constructor(media: GridTileMediaViewModel) {
|
||||
public constructor(media: UserMediaViewModel | RingingMediaViewModel) {
|
||||
this.media$ = new BehaviorSubject(media);
|
||||
this.vm = new GridTileViewModel(this.media$);
|
||||
}
|
||||
@@ -141,7 +140,7 @@ export class TileStoreBuilder {
|
||||
: null;
|
||||
|
||||
private readonly prevGridByMedia: Map<
|
||||
GridTileMediaViewModel,
|
||||
MediaViewModel,
|
||||
[GridTileData, number]
|
||||
> = new Map(
|
||||
this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const),
|
||||
@@ -206,7 +205,9 @@ export class TileStoreBuilder {
|
||||
* Sets up a grid tile for the given media. If this is never called for some
|
||||
* media, then that media will have no grid tile.
|
||||
*/
|
||||
public registerGridTile(media: GridTileMediaViewModel): void {
|
||||
public registerGridTile(
|
||||
media: UserMediaViewModel | RingingMediaViewModel,
|
||||
): void {
|
||||
if (DEBUG_ENABLED)
|
||||
logger.debug(
|
||||
`[TileStore, ${this.generation}] register grid tile: ${media.displayName$.value}`,
|
||||
@@ -214,10 +215,8 @@ export class TileStoreBuilder {
|
||||
|
||||
if (this.spotlight !== null) {
|
||||
// We actually *don't* want spotlight speakers to appear in both the
|
||||
// spotlight and the grid, so they're filtered out here. (Unknown
|
||||
// participants never make it into the spotlight.)
|
||||
// spotlight and the grid, so they're filtered out here
|
||||
if (
|
||||
media.type !== "unknown participant" &&
|
||||
!(media.type === "user" && media.local) &&
|
||||
this.spotlight.media.includes(media)
|
||||
)
|
||||
|
||||
@@ -10,14 +10,8 @@ import { BehaviorSubject } from "rxjs";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel";
|
||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel";
|
||||
import { type UnknownParticipantMediaViewModel } from "./media/UnknownParticipantMediaViewModel";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||
|
||||
export type GridTileMediaViewModel =
|
||||
| UserMediaViewModel
|
||||
| RingingMediaViewModel
|
||||
| UnknownParticipantMediaViewModel;
|
||||
|
||||
let nextId = 0;
|
||||
function createId(): string {
|
||||
return (nextId++).toString();
|
||||
@@ -29,7 +23,9 @@ export class GridTileViewModel {
|
||||
public readonly showOutline$: Behavior<boolean> = this._showOutline$;
|
||||
|
||||
public constructor(
|
||||
public readonly media$: Behavior<GridTileMediaViewModel>,
|
||||
public readonly media$: Behavior<
|
||||
UserMediaViewModel | RingingMediaViewModel
|
||||
>,
|
||||
) {}
|
||||
|
||||
public setShowOutline(value: boolean): void {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { type BehaviorSubject } from "rxjs";
|
||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||
import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts";
|
||||
import { type UnknownParticipantMediaViewModel } from "./media/UnknownParticipantMediaViewModel.ts";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts";
|
||||
import {
|
||||
type GridTileViewModel,
|
||||
@@ -19,29 +18,25 @@ import {
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
|
||||
|
||||
export type GridMediaViewModel =
|
||||
| UserMediaViewModel
|
||||
| UnknownParticipantMediaViewModel;
|
||||
|
||||
export interface GridLayoutMedia {
|
||||
type: "grid";
|
||||
edgeToEdge: false;
|
||||
spotlight?: MediaViewModel[];
|
||||
grid: GridMediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightLandscapeLayoutMedia {
|
||||
type: "spotlight-landscape";
|
||||
edgeToEdge: boolean;
|
||||
spotlight: MediaViewModel[];
|
||||
grid: GridMediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightPortraitLayoutMedia {
|
||||
type: "spotlight-portrait";
|
||||
edgeToEdge: false;
|
||||
spotlight: MediaViewModel[];
|
||||
grid: GridMediaViewModel[];
|
||||
grid: UserMediaViewModel[];
|
||||
}
|
||||
|
||||
export interface SpotlightExpandedLayoutMedia {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/*
|
||||
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 { constant } from "../Behavior";
|
||||
import { type BaseMediaViewModel } from "./MediaViewModel";
|
||||
|
||||
/**
|
||||
* Media representing a LiveKit participant that cannot be mapped to any
|
||||
* MatrixRTC member of the session.
|
||||
*
|
||||
* MSC4143 asks clients to surface such streams rather than silently ignore
|
||||
* them, because they can signal an eavesdropping or impersonation attack. We
|
||||
* therefore give them a tile, but never render their audio or video.
|
||||
*
|
||||
* There is no Matrix user behind this media, so `userId` and `displayName$`
|
||||
* carry the LiveKit identity instead, purely so that debugging output stays
|
||||
* meaningful.
|
||||
*/
|
||||
export interface UnknownParticipantMediaViewModel extends BaseMediaViewModel {
|
||||
type: "unknown participant";
|
||||
/**
|
||||
* The LiveKit identity under which the participant connected. Exposed for
|
||||
* debugging.
|
||||
*/
|
||||
rtcBackendIdentity: string;
|
||||
/**
|
||||
* The URL of the LiveKit focus on which the participant was seen. Exposed for
|
||||
* debugging.
|
||||
*/
|
||||
focusUrl: string;
|
||||
}
|
||||
|
||||
export interface UnknownParticipantMediaInputs {
|
||||
id: string;
|
||||
rtcBackendIdentity: string;
|
||||
focusUrl: string;
|
||||
}
|
||||
|
||||
export function createUnknownParticipantMedia({
|
||||
id,
|
||||
rtcBackendIdentity,
|
||||
focusUrl,
|
||||
}: UnknownParticipantMediaInputs): UnknownParticipantMediaViewModel {
|
||||
return {
|
||||
type: "unknown participant",
|
||||
id,
|
||||
userId: rtcBackendIdentity,
|
||||
displayName$: constant(rtcBackendIdentity),
|
||||
mxcAvatarUrl$: constant(undefined),
|
||||
rtcBackendIdentity,
|
||||
focusUrl,
|
||||
};
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
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 { expect, within } from "storybook/test";
|
||||
import { type JSX, useMemo } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { GridTile } from "./GridTile";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { createUnknownParticipantMedia } from "../state/media/UnknownParticipantMediaViewModel";
|
||||
|
||||
interface UnknownParticipantTileStoryProps {
|
||||
width: number;
|
||||
height: number;
|
||||
rtcBackendIdentity: string;
|
||||
showNameTags: boolean;
|
||||
showOutline: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a GridTile for a LiveKit participant that maps to no MatrixRTC
|
||||
* member, driven by primitive props so that Storybook can document them.
|
||||
*/
|
||||
function UnknownParticipantTile({
|
||||
width,
|
||||
height,
|
||||
rtcBackendIdentity,
|
||||
...props
|
||||
}: UnknownParticipantTileStoryProps): JSX.Element {
|
||||
const vm = useMemo(
|
||||
() =>
|
||||
new GridTileViewModel(
|
||||
constant(
|
||||
createUnknownParticipantMedia({
|
||||
id: `unknown:https://rtc.example.org:${rtcBackendIdentity}`,
|
||||
rtcBackendIdentity,
|
||||
focusUrl: "https://rtc.example.org",
|
||||
}),
|
||||
),
|
||||
),
|
||||
[rtcBackendIdentity],
|
||||
);
|
||||
return (
|
||||
<GridTile
|
||||
vm={vm}
|
||||
onOpenProfile={null}
|
||||
targetWidth={width}
|
||||
targetHeight={height}
|
||||
style={{ width, height }}
|
||||
showSpeakingIndicators={false}
|
||||
showRingingStatus={false}
|
||||
focusable
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
component: UnknownParticipantTile,
|
||||
argTypes: {
|
||||
width: { control: { type: "range", min: 80, max: 800, step: 10 } },
|
||||
height: { control: { type: "range", min: 80, max: 600, step: 10 } },
|
||||
},
|
||||
} satisfies Meta<typeof UnknownParticipantTile>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const UnknownParticipant: Story = {
|
||||
args: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
rtcBackendIdentity: "@rogue:example.org:DEVICE",
|
||||
showNameTags: true,
|
||||
showOutline: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Unknown participant")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.queryByText("@rogue:example.org:DEVICE"),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
/** Narrow tiles hide the name tag, so the label goes with it. */
|
||||
export const UnknownParticipantNarrow: Story = {
|
||||
args: {
|
||||
...UnknownParticipant.args,
|
||||
width: 90,
|
||||
height: 90,
|
||||
},
|
||||
};
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
createRingingMedia,
|
||||
type RingingMediaViewModel,
|
||||
} from "../state/media/RingingMediaViewModel";
|
||||
import { createUnknownParticipantMedia } from "../state/media/UnknownParticipantMediaViewModel";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
@@ -166,33 +165,3 @@ test("GridTile displays ringing media", async () => {
|
||||
act(() => pickupState$.next("decline"));
|
||||
screen.getByText("Call ended");
|
||||
});
|
||||
|
||||
test("GridTile displays an unknown participant", async () => {
|
||||
const vm = createUnknownParticipantMedia({
|
||||
id: "unknown:https://rtc-example.org:rogue",
|
||||
rtcBackendIdentity: "rogue",
|
||||
focusUrl: "https://rtc-example.org",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ReactionsSenderProvider vm={callVm} rtcSession={fakeRtcSession}>
|
||||
<GridTile
|
||||
vm={new GridTileViewModel(constant(vm))}
|
||||
onOpenProfile={() => {}}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
showSpeakingIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
showOutline
|
||||
focusable
|
||||
/>
|
||||
</ReactionsSenderProvider>,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// The tile is labelled as unknown rather than after its LiveKit identity
|
||||
screen.getByText("Unknown participant");
|
||||
expect(screen.queryByText("rogue")).toBeNull();
|
||||
// There is no user to show an avatar for
|
||||
expect(container.querySelector("[data-style]")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -51,7 +51,6 @@ import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewM
|
||||
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||
import { type UnknownParticipantMediaViewModel } from "../state/media/UnknownParticipantMediaViewModel";
|
||||
import { RingingStatus } from "./RingingStatus";
|
||||
|
||||
interface TileProps {
|
||||
@@ -98,44 +97,6 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface UnknownParticipantTileProps extends Omit<
|
||||
TileProps,
|
||||
"displayName" | "mxcAvatarUrl"
|
||||
> {
|
||||
vm: UnknownParticipantMediaViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* A blank tile standing in for a LiveKit participant that cannot be mapped to
|
||||
* any MatrixRTC member. MSC4143 asks clients to surface such streams rather
|
||||
* than ignore them. No media is rendered for them.
|
||||
*/
|
||||
const UnknownParticipantTile: FC<UnknownParticipantTileProps> = ({
|
||||
vm,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MediaView
|
||||
className={classNames(className, styles.tile)}
|
||||
video={undefined}
|
||||
userId={vm.userId}
|
||||
unencryptedWarning={false}
|
||||
videoEnabled={false}
|
||||
mirror={false}
|
||||
showAvatar={false}
|
||||
displayName={t("video_tile.unknown_participant")}
|
||||
mxcAvatarUrl={undefined}
|
||||
rtcBackendIdentity={vm.rtcBackendIdentity}
|
||||
focusUrl={vm.focusUrl}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
UnknownParticipantTile.displayName = "UnknownParticipantTile";
|
||||
|
||||
interface UserMediaTileProps extends TileProps {
|
||||
vm: UserMediaViewModel;
|
||||
showSpeakingIndicators: boolean;
|
||||
@@ -493,15 +454,6 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else if (media.type === "unknown participant") {
|
||||
return (
|
||||
<UnknownParticipantTile
|
||||
ref={ref}
|
||||
vm={media}
|
||||
className={classNames(className, { [styles.outline]: showOutline })}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else if (media.local) {
|
||||
return (
|
||||
<LocalUserMediaTile
|
||||
|
||||
+9
-17
@@ -56,11 +56,6 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
displayName: string;
|
||||
mxcAvatarUrl: string | undefined;
|
||||
avatarStyle?: "solid" | "translucent";
|
||||
/**
|
||||
* Whether to show an avatar when there is no video. Off for media that has no
|
||||
* Matrix user behind it.
|
||||
*/
|
||||
showAvatar?: boolean;
|
||||
background?: "solid" | "transparent";
|
||||
focusable: boolean;
|
||||
primaryButton?: ReactNode;
|
||||
@@ -100,7 +95,6 @@ export const MediaView: FC<Props> = ({
|
||||
displayName,
|
||||
mxcAvatarUrl,
|
||||
avatarStyle = "solid",
|
||||
showAvatar = true,
|
||||
background = "solid",
|
||||
focusable,
|
||||
primaryButton,
|
||||
@@ -182,17 +176,15 @@ export const MediaView: FC<Props> = ({
|
||||
<div className={styles.speakingBorder} />
|
||||
</div>
|
||||
)}
|
||||
{showAvatar && (
|
||||
<Avatar
|
||||
id={userId}
|
||||
name={displayName}
|
||||
size={avatarSize}
|
||||
src={mxcAvatarUrl}
|
||||
data-style={avatarStyle}
|
||||
className={styles.avatar}
|
||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||
/>
|
||||
)}
|
||||
<Avatar
|
||||
id={userId}
|
||||
name={displayName}
|
||||
size={avatarSize}
|
||||
src={mxcAvatarUrl}
|
||||
data-style={avatarStyle}
|
||||
className={styles.avatar}
|
||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||
/>
|
||||
{video?.publication !== undefined && (
|
||||
<VideoTrack
|
||||
trackRef={video}
|
||||
|
||||
@@ -49,8 +49,19 @@ export default defineConfig(({ mode }) => {
|
||||
cssCodeSplit: false,
|
||||
lib: {
|
||||
formats: ["es" as const],
|
||||
entry: "./component/index.tsx",
|
||||
fileName: "element-call",
|
||||
entry: {
|
||||
// The component
|
||||
"element-call": "./component/index.tsx",
|
||||
// What a host needs to talk about it, without it (see component/api.ts).
|
||||
// Built as an entry point of its own so that importing it does not
|
||||
// load the component: the bundler gives it a chunk that reaches only
|
||||
// what it imports.
|
||||
api: "./component/api.ts",
|
||||
},
|
||||
fileName: (_format, entryName) => `${entryName}.js`,
|
||||
// The one stylesheet (see cssCodeSplit) keeps the name it had when the
|
||||
// component was the only entry
|
||||
cssFileName: "element-call",
|
||||
},
|
||||
rollupOptions: {
|
||||
// The host already has these, and a second copy of any of them does not
|
||||
|
||||
Reference in New Issue
Block a user