Compare commits

...

32 Commits

Author SHA1 Message Date
Robin
0f8c4675cf Make condition simpler 2025-06-24 08:28:15 -04:00
Robin
0c27610119 Remove usages of forwardRef
It has been deprecated in React 19, which allows functional components to receive refs just like any other prop.
2025-06-23 23:06:33 -04:00
Robin
f86c9fe0a0 Merge pull request #3355 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-web to v8
2025-06-23 22:10:07 -04:00
renovate[bot]
3ae535bf31 Update dependency @vector-im/compound-web to v8 2025-06-23 00:39:22 +00:00
Robin
5e2e94d794 Refactor media devices to live outside React as Observables (#3334)
* Refactor media devices to live outside React as Observables

This moves the media devices state out of React to further our transition to a MVVM architecture in which we can more easily model and store complex application state. I have created an AppViewModel to act as the overarching state holder for any future non-React state we end up creating, and the MediaDevices reside within this. We should move more application logic (including the CallViewModel itself) there in the future.

* Address review feedback

* Fixes from ios debugging session: (#3342)

- dont use preferred vs selected concept in controlled media. Its not needed since we dont use the id for actual browser media devices (the id's are not even actual browser media devices)
  - add more logging
  - add more conditions to not accidently set a deviceId that is not a browser deviceId but one provided via controlled.

---------

Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2025-06-20 18:37:25 +02:00
renovate[bot]
5bf7361d01 Update dependency livekit-client to v2.13.8 (#3352)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-20 10:01:31 +02:00
renovate[bot]
0fe8d16740 Update dependency @livekit/components-react to v2.9.10 (#3349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 16:42:15 +02:00
renovate[bot]
f1a2002072 Update dependency livekit-client to v2.13.7 (#3350)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 16:40:28 +02:00
renovate[bot]
1af1a8ed0b Update dependency livekit-client to v2.13.6 (#3348)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 13:19:31 +02:00
renovate[bot]
0970db8297 Update dependency livekit-client to v2.13.5 (#3343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 13:59:35 +02:00
Robin
7f4a5a8bab Merge pull request #3338 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v4.0.4
2025-06-16 08:50:53 -04:00
renovate[bot]
442fc1fc16 Update dependency @vector-im/compound-design-tokens to v4.0.4 2025-06-16 01:46:00 +00:00
fkwp
32b6250cc3 fix: Force the docker image for lk-jwt-service to be pulled everytime (#3325)
lk-jwt-service latest-ci to be force pulled every time
2025-06-12 16:42:06 +02:00
Timo
4a6dbf350f bump js-sdk to latest develop (containing a fix for membership manager resend join event issue) (#3333)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-06-12 16:28:00 +02:00
fkwp
29968a3eba Apply new timing constants to MatrixRTC session (#3332)
Apply new timing constants
2025-06-12 14:10:54 +02:00
fkwp
aa092c2195 Merge pull request #3323 from element-hq/fkwp/dev_build
Add developer build mode to GitHub action build-element-call.yaml
2025-06-11 22:56:38 +02:00
Timo
1425de8726 Expose keep alive event configurarion in ec config and update defaults. (#3303) 2025-06-10 18:09:52 +02:00
fkwp
c1aa1888f0 Merge pull request #3327 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.13.4
2025-06-10 16:12:11 +02:00
fkwp
58ebbfeed7 Merge pull request #3326 from element-hq/fkwp/doc_self-hosting_update
Add tools section to self-hosting guide and adding Matrixtester to it
2025-06-10 15:59:12 +02:00
Timo
02f3f1b71a Improvements to audio renderer codebase. (#3308)
* Improvements to audio renderer codebase.

* review
2025-06-10 13:00:21 +00:00
renovate[bot]
f7a6e20ae4 Update dependency livekit-client to v2.13.4 2025-06-10 12:31:41 +00:00
fkwp
378d59e948 prettier 2025-06-10 14:27:15 +02:00
fkwp
a07dbe645e tools section to self-hosting guide and adding Matrixtester to it 2025-06-10 14:24:59 +02:00
Timo
a596eb71a9 add onAudioPlaybackStarted callback for controls (#3309)
* add onAudioTrackReady callback for controls

* more details in controls.md for `onAudioPlaybackStarted`
2025-06-10 12:35:04 +02:00
Robin
c8a1cae18b Merge pull request #2874 from element-hq/renovate/major-react-monorepo
Update react monorepo to v19 (major)
2025-06-09 11:59:26 -04:00
Robin
4f1450aaf0 Replace react-use-clipboard with copy-to-clipboard
We didn't need the complexity of the (admittedly very small) React hook, and the package hasn't declared compatibility with React 19, so let's just switch to copying things manually via copy-to-clipboard.
2025-06-09 11:53:58 -04:00
Robin
8704b44b78 Update test snapshots for React 19 changes 2025-06-09 11:48:01 -04:00
Robin
0ee11af3c5 Upgrade react-spring to v10
For React 19 compatibility.
2025-06-09 11:47:47 -04:00
Andrew Ferrazzutti
13fac57b01 Build Docker image on slim base (#3204)
* Build Docker image on slim base

* Run Playwright tests against Docker container

For Playwright end-to-end tests in CI, instead of running a development
webserver with `yarn dev`, build and deploy a Docker container for
Element Call and use that as the webserver to test against.

* Shut down playwright webserver gracefully

When using a containerized webserver, this stops the container once
tests finish.

* Increase Playwright timeout in CI

---------

Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-06-06 12:04:57 -04:00
Robin
e0ce58b2a2 Merge pull request #3320 from element-hq/revert-3319-revert-3313-misc/migrate-to-maven-central-portal
Upgrade Android publishing to use Maven Central Portal (again)
2025-06-06 10:18:20 -04:00
Jorge Martin Espinosa
34df584366 Revert "Revert "Upgrade Android publishing to use Maven Central Portal"" 2025-06-05 11:20:47 +02:00
renovate[bot]
789f37611b Update react monorepo to v19 2025-06-03 13:28:48 +00:00
64 changed files with 1757 additions and 1601 deletions

View File

@@ -30,7 +30,7 @@ jobs:
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
timeout-minutes: 10
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
@@ -49,9 +49,9 @@ jobs:
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
docker ps
- name: Copy config file
run: cp config/config.devenv.json public/config.json
- name: Run Playwright tests
env:
USE_DOCKER: 1
run: yarn playwright test
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: ${{ !cancelled() }}

View File

@@ -6,7 +6,7 @@ COPY ./dist /dist
WORKDIR /dist/assets
RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json
FROM nginxinc/nginx-unprivileged:alpine
FROM nginxinc/nginx-unprivileged:alpine-slim
COPY --from=builder ./dist /app

View File

@@ -8,5 +8,12 @@
"features": {
"feature_use_device_session_member_events": true
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf"
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 4000
}
}

View File

@@ -11,5 +11,12 @@
"features": {
"feature_use_device_session_member_events": true
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf"
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 4000
}
}

View File

@@ -5,6 +5,14 @@
"server_name": "call-unstable.ems.host"
}
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 4000
},
"posthog": {
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
"api_host": "https://posthog-element-call.element.io"

View File

@@ -4,6 +4,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
pull_policy: always
hostname: auth-server
environment:
- LIVEKIT_JWT_PORT=6080

View File

@@ -19,3 +19,7 @@ On mobile platforms (iOS, Android), web views do not reliably support selecting
- `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default.
- `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu.
This button is only shown on iOS. (`userAgent.includes("iPhone")`)
- `controls.onAudioPlaybackStarted: ((id: string) => void) | undefined`: This will be called the first time we start
playing audio in the webview. It can be helpful to do device setup on the native app when the webviews audio is ready.
In particular android is using it to setup the output channel so that the call volume can
be controlled by the hardware volume rocker.

View File

@@ -255,13 +255,17 @@ self-hosters and developers working with Element Call.
- [How to resolve stuck MatrixRTC calls](https://sspaeth.de/2025/02/how-to-resolve-stuck-matrixrtc-calls/)
## 🛠️ How-Tos & Tutorials
## 📝 How-Tos & Tutorials
- [MatrixRTC aka Element-call setup (Geek warning)](https://sspaeth.de/2024/11/sfu/)
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
## 🛠️ Tools
- [A Matrix server sanity tester including tests for proper MatrixRTC setup](https://codeberg.org/spaetz/testmatrix)
## 🤝 Want to Contribute?
Have a guide or blog post you'd like to share? Open a

View File

@@ -9,4 +9,4 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
[plugins]
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.31.0" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.32.0" }

View File

@@ -27,7 +27,7 @@ android {
}
mavenPublishing {
publishToMavenCentral(SonatypeHost.S01, automaticRelease = true)
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
signAllPublications()

View File

@@ -58,7 +58,7 @@
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@react-spring/web": "^9.4.4",
"@react-spring/web": "^10.0.0",
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^3.0.0",
@@ -74,19 +74,20 @@
"@types/node": "^22.0.0",
"@types/pako": "^2.0.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/sdp-transform": "^2.4.5",
"@types/uuid": "10",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^7.12.0",
"@vector-im/compound-web": "^8.0.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^3.0.0",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
"eslint": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
@@ -117,11 +118,10 @@
"posthog-js": "1.160.3",
"prettier": "^3.0.0",
"qrcode": "^1.5.4",
"react": "18",
"react-dom": "18",
"react": "19",
"react-dom": "19",
"react-i18next": "^15.0.0",
"react-router-dom": "^7.0.0",
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sass": "^1.42.1",

View File

@@ -7,6 +7,10 @@ Please see LICENSE in the repository root for full details.
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.USE_DOCKER
? "http://localhost:8080"
: "https://localhost:3000";
/**
* See https://playwright.dev/docs/test-configuration.
*/
@@ -25,7 +29,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "https://localhost:3000",
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@@ -73,9 +77,13 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: "yarn dev",
url: "https://localhost:3000",
command: "./scripts/playwright-webserver-command.sh",
url: baseURL,
reuseExistingServer: !process.env.CI,
ignoreHTTPSErrors: true,
gracefulShutdown: {
signal: "SIGTERM",
timeout: 500,
},
},
});

View File

@@ -69,16 +69,27 @@ const CONFIG_JSON = {
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
* @param page
*/
async function setDevToolElementCallDevUrl(page: Page): Promise<void> {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://localhost:3000/room",
);
});
}
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
? async (page: Page): Promise<void> => {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"http://localhost:8080/room",
);
});
}
: async (page: Page): Promise<void> => {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://localhost:3000/room",
);
});
};
/**
* Registers a new user and returns page, clientHandle and mxId.

View File

@@ -0,0 +1,10 @@
#!/bin/sh
if [ -n "$USE_DOCKER" ]; then
set -ex
yarn build
docker build -t element-call:testing .
exec docker run --rm --name element-call-testing -p 8080:8080 -v ./config/config.devenv.json:/app/config.json:ro,Z element-call:testing
else
cp config/config.devenv.json public/config.json
exec yarn dev
fi

View File

@@ -19,10 +19,11 @@ import { ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { DisconnectedBanner } from "./DisconnectedBanner";
import { Initializer } from "./initializer";
import { MediaDevicesProvider } from "./livekit/MediaDevicesContext";
import { widget } from "./widget";
import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
@@ -50,7 +51,11 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
return children;
};
export const App: FC = () => {
interface Props {
vm: AppViewModel;
}
export const App: FC<Props> = ({ vm }) => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
Initializer.init()
@@ -72,7 +77,7 @@ export const App: FC = () => {
{loaded ? (
<Suspense fallback={null}>
<ClientProvider>
<MediaDevicesProvider>
<MediaDevicesContext.Provider value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => (
@@ -91,7 +96,7 @@ export const App: FC = () => {
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesProvider>
</MediaDevicesContext.Provider>
</ClientProvider>
</Suspense>
) : (

View File

@@ -6,12 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import {
type FC,
type HTMLAttributes,
type ReactNode,
forwardRef,
} from "react";
import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web";
@@ -24,23 +19,27 @@ import { EncryptionLock } from "./room/EncryptionLock";
import { useMediaQuery } from "./useMediaQuery";
interface HeaderProps extends HTMLAttributes<HTMLElement> {
ref?: Ref<HTMLElement>;
children: ReactNode;
className?: string;
}
export const Header = forwardRef<HTMLElement, HeaderProps>(
({ children, className, ...rest }, ref) => {
return (
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
);
},
);
export const Header: FC<HeaderProps> = ({
ref,
children,
className,
...rest
}) => {
return (
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
);
};
Header.displayName = "Header";

View File

@@ -0,0 +1,52 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { createContext, useContext, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type MediaDevices } from "./state/MediaDevices";
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
undefined,
);
export function useMediaDevices(): MediaDevices {
const mediaDevices = useContext(MediaDevicesContext);
if (mediaDevices === undefined)
throw new Error(
"useMediaDevices must be used within a MediaDevices context provider",
);
return mediaDevices;
}
/**
* A convenience hook to get the audio node configuration for the earpiece.
* It will check the `useAsEarpiece` of the `audioOutput` device and return
* the appropriate pan and volume values.
*
* @returns pan and volume values for the earpiece audio node configuration.
*/
export const useEarpieceAudioConfig = (): {
pan: number;
volume: number;
} => {
const devices = useMediaDevices();
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
// We use only the right speaker (pan = 1) for the earpiece.
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
const pan = useMemo(
() => (audioOutput?.virtualEarpiece ? 1 : 0),
[audioOutput?.virtualEarpiece],
);
// We also do lower the volume by a factor of 10 to optimize for the usecase where
// a user is holding the phone to their ear.
const volume = useMemo(
() => (audioOutput?.virtualEarpiece ? 0.1 : 1),
[audioOutput?.virtualEarpiece],
);
return { pan, volume };
};

View File

@@ -2,10 +2,10 @@
exports[`the content is rendered when the modal is open 1`] = `
<div
aria-labelledby="radix-:r4:"
aria-labelledby="radix-«r4»"
class="overlay animate modal dialog _glass_sepwu_8"
data-state="open"
id="radix-:r3:"
id="radix-«r3»"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
@@ -18,7 +18,7 @@ exports[`the content is rendered when the modal is open 1`] = `
>
<h2
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
id="radix-:r4:"
id="radix-«r4»"
>
My modal
</h2>
@@ -36,7 +36,7 @@ exports[`the content is rendered when the modal is open 1`] = `
exports[`the modal renders as a drawer in mobile viewports 1`] = `
<div
aria-labelledby="radix-:ra:"
aria-labelledby="radix-«ra»"
class="overlay modal drawer"
data-state="open"
data-vaul-animate="true"
@@ -45,7 +45,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
data-vaul-drawer=""
data-vaul-drawer-direction="bottom"
data-vaul-snap-points="false"
id="radix-:r9:"
id="radix-«r9»"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
@@ -60,7 +60,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
class="handle"
/>
<h2
id="radix-:ra:"
id="radix-«ra»"
style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
>
My modal

View File

@@ -2,10 +2,10 @@
exports[`Toast > renders 1`] = `
<button
aria-labelledby="radix-:r4:"
aria-labelledby="radix-«r4»"
class="overlay animate toast"
data-state="open"
id="radix-:r3:"
id="radix-«r3»"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
@@ -13,7 +13,7 @@ exports[`Toast > renders 1`] = `
>
<h3
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36"
id="radix-:r4:"
id="radix-«r4»"
>
Hello world!
</h3>

View File

@@ -5,11 +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 ComponentPropsWithoutRef,
forwardRef,
type MouseEvent,
} from "react";
import { type ComponentProps, type FC, type MouseEvent } from "react";
import { Link as CpdLink } from "@vector-im/compound-web";
import { type LinkProps, useHref, useLinkClickHandler } from "react-router-dom";
import classNames from "classnames";
@@ -26,31 +22,30 @@ export function useLink(
return [href, onClick];
}
type Props = Omit<
ComponentPropsWithoutRef<typeof CpdLink>,
"href" | "onClick"
> & { to: LinkProps["to"]; state?: unknown };
type Props = Omit<ComponentProps<typeof CpdLink>, "href" | "onClick"> & {
to: LinkProps["to"];
state?: unknown;
};
/**
* A version of Compound's link component that integrates with our router setup.
* This is only for app-internal links.
*/
export const Link = forwardRef<HTMLAnchorElement, Props>(function Link(
{ to, state, ...props },
ref,
) {
export const Link: FC<Props> = ({ ref, to, state, ...props }) => {
const [path, onClick] = useLink(to, state);
return <CpdLink ref={ref} {...props} href={path} onClick={onClick} />;
});
};
/**
* A link to an external web page, made to fit into blocks of text more subtly
* than the normal Compound link component.
*/
export const ExternalLink = forwardRef<
HTMLAnchorElement,
ComponentPropsWithoutRef<"a">
>(function ExternalLink({ className, children, ...props }, ref) {
export const ExternalLink: FC<ComponentProps<"a">> = ({
ref,
className,
children,
...props
}) => {
return (
<a
ref={ref}
@@ -62,4 +57,4 @@ export const ExternalLink = forwardRef<
{children}
</a>
);
});
};

View File

@@ -5,24 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ComponentPropsWithoutRef, forwardRef } from "react";
import { type ComponentProps, type FC } from "react";
import { Button } from "@vector-im/compound-web";
import type { LinkProps } from "react-router-dom";
import { useLink } from "./Link";
type Props = Omit<
ComponentPropsWithoutRef<typeof Button<"a">>,
"as" | "href"
> & { to: LinkProps["to"]; state?: unknown };
type Props = Omit<ComponentProps<typeof Button<"a">>, "as" | "href"> & {
to: LinkProps["to"];
state?: unknown;
};
/**
* A version of Compound's button component that acts as a link and integrates
* with our router setup.
*/
export const LinkButton = forwardRef<HTMLAnchorElement, Props>(
function LinkButton({ to, state, ...props }, ref) {
const [path, onClick] = useLink(to, state);
return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />;
},
);
export const LinkButton: FC<Props> = ({ ref, to, state, ...props }) => {
const [path, onClick] = useLink(to, state);
return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />;
};

View File

@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":rb5:"
aria-labelledby="«rb5»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -43,7 +43,7 @@ exports[`Can fully expand emoji picker 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":r7m:"
aria-labelledby="«r7m»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -74,7 +74,7 @@ exports[`Can lower hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby=":r36:"
aria-labelledby="«r36»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="secondary"
data-size="lg"
@@ -108,7 +108,7 @@ exports[`Can open menu 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":r0:"
aria-labelledby="«r0»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -139,7 +139,7 @@ exports[`Can raise hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby=":r1j:"
aria-labelledby="«r1j»"
class="_button_vczzf_8 raisedButton _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"

View File

@@ -14,6 +14,7 @@ export interface ConfigOptions {
api_key: string;
api_host: string;
};
/**
* The Sentry endpoint to which crash data will be sent.
* This is only used in the full package of Element Call.
@@ -22,6 +23,7 @@ export interface ConfigOptions {
DSN: string;
environment: string;
};
/**
* The rageshake server to which feedback and debug logs will be sent.
* This is only used in the full package of Element Call.
@@ -66,6 +68,7 @@ export interface ConfigOptions {
* Allow to join group calls without audio and video.
*/
feature_group_calls_without_video_and_audio?: boolean;
/**
* Send device-specific call session membership state events instead of
* legacy user-specific call membership state events.
@@ -86,6 +89,7 @@ export interface ConfigOptions {
* Defines whether participants should start with audio enabled by default.
*/
enable_audio?: boolean;
/**
* Defines whether participants should start with video enabled by default.
*/
@@ -109,19 +113,38 @@ export interface ConfigOptions {
* How long (in milliseconds) to wait before rotating end-to-end media encryption keys
* when someone leaves a call.
*/
wait_for_key_rotation_ms?: number;
/** @deprecated use wait_for_key_rotation_ms instead */
key_rotation_on_leave_delay?: number;
/**
* How often (in milliseconds) keep-alive messages should be sent to the server for
* the MatrixRTC membership event.
* The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart)
* that the server waits before sending the leave MatrixRTC membership event.
*/
delayed_leave_event_delay_ms?: number;
/** @deprecated use delayed_leave_event_delay_ms instead */
membership_server_side_expiry_timeout?: number;
/**
* The time interval (in milliseconds) at which the client sends membership keep-alive
* messages to the server by restarting the timer for the delayed leave event.
*/
delayed_leave_event_restart_ms?: number;
/** @deprecated use delayed_leave_event_restart_ms instead */
membership_keep_alive_period?: number;
/**
* How long (in milliseconds) after the last keep-alive the server should expire the
* MatrixRTC membership event.
* How long we wait before retrying after a network error on any of the requests.
*/
membership_server_side_expiry_timeout?: number;
network_error_retry_ms?: number;
/**
* The timeout (in milliseconds) after we joined the call, that our membership should expire
* unless we have explicitly updated it.
*
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
*/
membership_event_expiry_ms?: number;
};
}

View File

@@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject, Subject } from "rxjs";
import { Subject } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
const logger = rootLogger.getChild("[controlled-output]");
export interface Controls {
canEnterPip(): boolean;
@@ -20,6 +23,7 @@ export interface Controls {
/** @deprecated use onAudioDeviceSelect instead*/
onOutputDeviceSelect?: (id: string) => void;
onAudioDeviceSelect?: (id: string) => void;
onAudioPlaybackStarted?: () => void;
/** @deprecated use setAudioEnabled instead*/
setOutputEnabled(enabled: boolean): void;
setAudioEnabled(enabled: boolean): void;
@@ -41,12 +45,11 @@ export interface OutputDevice {
* If pipMode is enabled, EC will render a adapted call view layout.
*/
export const setPipEnabled$ = new Subject<boolean>();
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
// We want the devices that have been set during loading to be available immediately once loaded.
export const availableOutputDevices$ = new BehaviorSubject<OutputDevice[]>([]);
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
// We want the device that has been set during loading to be available immediately once loaded.
export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
export const availableOutputDevices$ = new Subject<OutputDevice[]>();
export const outputDevice$ = new Subject<string | undefined>();
/**
* This allows the os to mute the call if the user
* presses the volume down button when it is at the minimum volume.
@@ -54,7 +57,13 @@ export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
* This should also be used to display a darkened overlay screen letting the user know that audio is muted.
*/
export const setAudioEnabled$ = new Subject<boolean>();
let playbackStartedEmitted = false;
export const setPlaybackStarted = (): void => {
if (!playbackStartedEmitted) {
playbackStartedEmitted = true;
window.controls.onAudioPlaybackStarted?.();
}
};
window.controls = {
canEnterPip(): boolean {
return setPipEnabled$.observed;
@@ -68,12 +77,15 @@ window.controls = {
setPipEnabled$.next(false);
},
setAvailableAudioDevices(devices: OutputDevice[]): void {
logger.info("setAvailableAudioDevices called from native:", devices);
availableOutputDevices$.next(devices);
},
setAudioDevice(id: string): void {
logger.info("setAudioDevice called from native", id);
outputDevice$.next(id);
},
setAudioEnabled(enabled: boolean): void {
logger.info("setAudioEnabled called from native:", enabled);
if (!setAudioEnabled$.observed)
throw new Error(
"Output controls are disabled. No setAudioEnabled$ observer",

View File

@@ -6,28 +6,32 @@ Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import { type FormEventHandler, forwardRef, type ReactNode } from "react";
import {
type FC,
type Ref,
type FormEventHandler,
type ReactNode,
} from "react";
import styles from "./Form.module.css";
interface FormProps {
ref?: Ref<HTMLFormElement>;
className: string;
onSubmit: FormEventHandler<HTMLFormElement>;
children: ReactNode[];
}
export const Form = forwardRef<HTMLFormElement, FormProps>(
({ children, className, onSubmit }, ref) => {
return (
<form
onSubmit={onSubmit}
className={classNames(styles.form, className)}
ref={ref}
>
{children}
</form>
);
},
);
export const Form: FC<FormProps> = ({ ref, children, className, onSubmit }) => {
return (
<form
onSubmit={onSubmit}
className={classNames(styles.form, className)}
ref={ref}
>
{children}
</form>
);
};
Form.displayName = "Form";

View File

@@ -18,11 +18,10 @@ import {
type ComponentType,
type Dispatch,
type FC,
type LegacyRef,
type ReactNode,
type Ref,
type SetStateAction,
createContext,
forwardRef,
memo,
useContext,
useEffect,
@@ -162,7 +161,7 @@ const windowHeightObservable$ = fromEvent(window, "resize").pipe(
);
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref: LegacyRef<R>;
ref?: Ref<R>;
model: LayoutModel;
/**
* Component creating an invisible "slot" for a tile to go in.
@@ -171,7 +170,7 @@ export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
}
export interface TileProps<Model, R extends HTMLElement> {
ref: LegacyRef<R>;
ref?: Ref<R>;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
/**
@@ -297,14 +296,13 @@ export function Grid<
// render of Grid causes a re-render of Layout, which in turn re-renders Grid
const LayoutMemo = useMemo(
() =>
memo(
forwardRef<
LayoutRef,
LayoutMemoProps<LayoutModel, TileModel, LayoutRef>
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode {
return <Layout {...props} ref={ref} />;
}),
),
memo(function LayoutMemo({
ref,
Layout,
...props
}: LayoutMemoProps<LayoutModel, TileModel, LayoutRef>): ReactNode {
return <Layout {...props} ref={ref} />;
}),
[],
);

View File

@@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type CSSProperties, forwardRef, useCallback, useMemo } from "react";
import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
} from "react";
import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
@@ -33,7 +38,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const alignment = useObservableEagerState(
useInitial(() =>
@@ -68,10 +73,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
)}
</div>
);
}),
},
// The scrolling part of the layout is where all the grid tiles live
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
const { width, height: minHeight } = useObservableEagerState(minBounds$);
@@ -98,5 +103,5 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
))}
</div>
);
}),
},
});

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 { forwardRef, useCallback, useMemo } from "react";
import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -24,12 +24,12 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
}) => ({
scrollingOnTop: false,
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
useUpdateLayout();
return <div ref={ref} />;
}),
},
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
@@ -66,5 +66,5 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
</Slot>
</div>
);
}),
},
});

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 { forwardRef, useCallback } from "react";
import { type ReactNode, useCallback } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
@@ -22,10 +22,11 @@ export const makeSpotlightExpandedLayout: CallLayout<
> = ({ pipAlignment$ }) => ({
scrollingOnTop: true,
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
{ model, Slot },
fixed: function SpotlightExpandedLayoutFixed({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
return (
@@ -37,12 +38,13 @@ export const makeSpotlightExpandedLayout: CallLayout<
/>
</div>
);
}),
},
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
{ model, Slot },
scrolling: function SpotlightExpandedLayoutScrolling({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
@@ -69,5 +71,5 @@ export const makeSpotlightExpandedLayout: CallLayout<
)}
</div>
);
}),
},
});

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 { forwardRef } from "react";
import { type ReactNode } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -24,10 +24,11 @@ export const makeSpotlightLandscapeLayout: CallLayout<
> = ({ minBounds$ }) => ({
scrollingOnTop: false,
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
{ model, Slot },
fixed: function SpotlightLandscapeLayoutFixed({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
useObservableEagerState(minBounds$);
@@ -43,12 +44,13 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<div className={styles.grid} />
</div>
);
}),
},
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling(
{ model, Slot },
scrolling: function SpotlightLandscapeLayoutScrolling({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds$);
@@ -69,5 +71,5 @@ export const makeSpotlightLandscapeLayout: CallLayout<
</div>
</div>
);
}),
},
});

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 CSSProperties, forwardRef } from "react";
import { type ReactNode, type CSSProperties } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -30,10 +30,11 @@ export const makeSpotlightPortraitLayout: CallLayout<
> = ({ minBounds$ }) => ({
scrollingOnTop: false,
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
{ model, Slot },
fixed: function SpotlightPortraitLayoutFixed({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
return (
@@ -47,12 +48,13 @@ export const makeSpotlightPortraitLayout: CallLayout<
</div>
</div>
);
}),
},
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
{ model, Slot },
scrolling: function SpotlightPortraitLayoutScrolling({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
const { width } = useObservableEagerState(minBounds$);
@@ -90,5 +92,5 @@ export const makeSpotlightPortraitLayout: CallLayout<
</div>
</div>
);
}),
},
});

View File

@@ -9,10 +9,10 @@ import {
type ChangeEvent,
type FC,
type ForwardedRef,
forwardRef,
type ReactNode,
useId,
type JSX,
type Ref,
} from "react";
import classNames from "classnames";
@@ -54,6 +54,7 @@ function Field({ children, className }: FieldProps): JSX.Element {
}
interface InputFieldProps {
ref?: Ref<HTMLInputElement | HTMLTextAreaElement>;
label?: string;
type: string;
prefix?: string;
@@ -78,88 +79,81 @@ interface InputFieldProps {
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
}
export const InputField = forwardRef<
HTMLInputElement | HTMLTextAreaElement,
InputFieldProps
>(
(
{
id,
label,
className,
type,
checked,
prefix,
suffix,
description,
disabled,
min,
...rest
},
ref,
) => {
const descriptionId = useId();
export const InputField: FC<InputFieldProps> = ({
ref,
id,
label,
className,
type,
checked,
prefix,
suffix,
description,
disabled,
min,
...rest
}) => {
const descriptionId = useId();
return (
<Field
className={classNames(
type === "checkbox" ? styles.checkboxField : styles.inputField,
{
[styles.prefix]: !!prefix,
[styles.disabled]: disabled,
},
className,
)}
>
{prefix && <span>{prefix}</span>}
{type === "textarea" ? (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<textarea
id={id}
ref={ref as ForwardedRef<HTMLTextAreaElement>}
disabled={disabled}
aria-describedby={descriptionId}
{...rest}
/>
) : (
<input
id={id}
ref={ref as ForwardedRef<HTMLInputElement>}
type={type}
checked={checked}
disabled={disabled}
aria-describedby={descriptionId}
min={min}
{...rest}
/>
)}
return (
<Field
className={classNames(
type === "checkbox" ? styles.checkboxField : styles.inputField,
{
[styles.prefix]: !!prefix,
[styles.disabled]: disabled,
},
className,
)}
>
{prefix && <span>{prefix}</span>}
{type === "textarea" ? (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<textarea
id={id}
ref={ref as ForwardedRef<HTMLTextAreaElement>}
disabled={disabled}
aria-describedby={descriptionId}
{...rest}
/>
) : (
<input
id={id}
ref={ref as ForwardedRef<HTMLInputElement>}
type={type}
checked={checked}
disabled={disabled}
aria-describedby={descriptionId}
min={min}
{...rest}
/>
)}
<label htmlFor={id}>
{type === "checkbox" && (
<div className={styles.checkbox}>
<CheckIcon />
</div>
)}
{label}
</label>
{suffix && <span>{suffix}</span>}
{description && (
<p
id={descriptionId}
className={
label
? styles.description
: classNames(styles.description, styles.noLabel)
}
>
{description}
</p>
<label htmlFor={id}>
{type === "checkbox" && (
<div className={styles.checkbox}>
<CheckIcon />
</div>
)}
</Field>
);
},
);
{label}
</label>
{suffix && <span>{suffix}</span>}
{description && (
<p
id={descriptionId}
className={
label
? styles.description
: classNames(styles.description, styles.noLabel)
}
>
{description}
</p>
)}
</Field>
);
};
InputField.displayName = "InputField";

View File

@@ -17,12 +17,14 @@ import { type ReactNode } from "react";
import { useTracks } from "@livekit/components-react";
import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "./MediaDevicesContext";
import * as MediaDevicesContext from "../MediaDevicesContext";
import { MatrixAudioRenderer } from "./MatrixAudioRenderer";
import { mockTrack } from "../utils/test";
import { mockMediaDevices, mockTrack } from "../utils/test";
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;
beforeEach(() => {
vi.stubGlobal("AudioContext", TestAudioContextConstructor);
});
@@ -51,18 +53,24 @@ vi.mocked(useTracks).mockReturnValue(tracks);
it("should render for member", () => {
const { container, queryAllByTestId } = render(
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>,
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
);
expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(1);
});
it("should not render without member", () => {
const memberships = [
{ sender: "othermember", deviceId: "123" },
] as CallMembership[];
const { container, queryAllByTestId } = render(
<MatrixAudioRenderer
members={[{ sender: "othermember", deviceId: "123" }] as CallMembership[]}
/>,
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer members={memberships} />
</MediaDevicesProvider>,
);
expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(0);
@@ -70,9 +78,11 @@ it("should not render without member", () => {
it("should not setup audioContext gain and pan if there is no need to.", () => {
render(
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>,
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
);
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
@@ -84,15 +94,18 @@ it("should not setup audioContext gain and pan if there is no need to.", () => {
expect(testAudioContext.gain.gain.value).toEqual(1);
expect(testAudioContext.pan.pan.value).toEqual(0);
});
it("should setup audioContext gain and pan", () => {
vi.spyOn(MediaDevicesContext, "useEarpieceAudioConfig").mockReturnValue({
pan: 1,
volume: 0.1,
});
render(
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>,
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
);
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;

View File

@@ -14,11 +14,13 @@ import {
type AudioTrackProps,
} from "@livekit/components-react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "./MediaDevicesContext";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import * as controls from "../controls";
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
export interface MatrixAudioRendererProps {
/**
* The list of participants to render audio for.
@@ -59,6 +61,7 @@ export function MatrixAudioRenderer({
);
const loggedInvalidIdentities = useRef(new Set<string>());
/**
* Log an invalid livekit track identity.
* A invalid identity is one that does not match any of the matrix rtc members.
@@ -96,6 +99,14 @@ export function MatrixAudioRenderer({
isValid
);
});
useEffect(() => {
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
logger.debug(
`All audio tracks have a matching matrix call member identity.`,
);
loggedInvalidIdentities.current.clear();
}
}, [tracks, validIdentities]);
// This component is also (in addition to the "only play audio for connected members" logic above)
// responsible for mimicking earpiece audio on iPhones.
@@ -204,6 +215,7 @@ function AudioTrackWithAudioNodes({
useContext ? [audioNodes.gain!, audioNodes.pan!] : [],
);
setTrackReady(true);
controls.setPlaybackStarted();
}, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]);
return (

View File

@@ -1,445 +0,0 @@
/*
Copyright 2023-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type FC,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type JSX,
} from "react";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { combineLatest, distinctUntilChanged, map, startWith } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import {
useSetting,
audioInput as audioInputSetting,
audioOutput as audioOutputSetting,
videoInput as videoInputSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
type Setting,
} from "../settings/settings";
import { outputDevice$, availableOutputDevices$ } from "../controls";
import { useUrlParams } from "../UrlParams";
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
export const EARPIECE_CONFIG_ID = "earpiece-id";
export type DeviceLabel =
| { type: "name"; name: string }
| { type: "number"; number: number }
| { type: "earpiece" }
| { type: "default"; name: string | null };
export interface MediaDeviceHandle {
/**
* A map from available device IDs to labels.
*/
available: Map<string, DeviceLabel>;
selectedId: string | undefined;
/**
* An additional device configuration that makes us use only one channel of the
* output device and a reduced volume.
*/
useAsEarpiece: boolean | undefined;
/**
* The group ID of the selected device.
*/
// This is exposed sort of ad-hoc because it's only needed for knowing when to
// restart the tracks of default input devices, and ideally this behavior
// would be encapsulated somehow…
selectedGroupId: string | undefined;
select: (deviceId: string) => void;
}
interface InputDevices {
audioInput: MediaDeviceHandle;
videoInput: MediaDeviceHandle;
startUsingDeviceNames: () => void;
stopUsingDeviceNames: () => void;
usingNames: boolean;
}
export interface MediaDevices extends Omit<InputDevices, "usingNames"> {
audioOutput: MediaDeviceHandle;
}
/**
* An observable that represents if we should display the devices menu for iOS.
* This implies the following
* - hide any input devices (they do not work anyhow on ios)
* - Show a button to show the native output picker instead.
* - Only show the earpiece toggle option if the earpiece is available:
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
*/
export const iosDeviceMenu$ = alwaysShowIphoneEarpieceSetting.value$.pipe(
map((v) => v || navigator.userAgent.includes("iPhone")),
);
function useSelectedId(
available: Map<string, DeviceLabel>,
preferredId: string | undefined,
): string | undefined {
return useMemo(() => {
if (available.size) {
// If the preferred device is available, use it. Or if every available
// device ID is falsy, the browser is probably just being paranoid about
// fingerprinting and we should still try using the preferred device.
// Worst case it is not available and the browser will gracefully fall
// back to some other device for us when requesting the media stream.
// Otherwise, select the first available device.
return (preferredId !== undefined && available.has(preferredId)) ||
(available.size === 1 && available.has(""))
? preferredId
: available.keys().next().value;
}
return undefined;
}, [available, preferredId]);
}
/**
* Hook to get access to a mediaDevice handle for a kind. This allows to list
* the available devices, read and set the selected device.
* @param kind Audio input, output or video output.
* @param setting The setting this handle's selection should be synced with.
* @param usingNames If the hook should query device names for the associated
* list.
* @returns A handle for the chosen kind.
*/
function useMediaDeviceHandle(
kind: MediaDeviceKind,
setting: Setting<string | undefined>,
usingNames: boolean,
): MediaDeviceHandle {
const hasRequestedPermissions = useRef(false);
const requestPermissions = usingNames || hasRequestedPermissions.current;
// Make sure we don't needlessly reset to a device observer without names,
// once permissions are already given
hasRequestedPermissions.current ||= usingNames;
// We use a bare device observer here rather than one of the fancy device
// selection hooks from @livekit/components-react, because
// useMediaDeviceSelect expects a room or track, which we don't have here, and
// useMediaDevices provides no way to request device names.
// Tragically, the only way to get device names out of LiveKit is to specify a
// kind, which then results in multiple permissions requests.
const deviceObserver$ = useMemo(
() =>
createMediaDeviceObserver(
kind,
() => logger.error("Error creating MediaDeviceObserver"),
requestPermissions,
).pipe(
startWith([]),
// This Observable emits new values whenever the browser fires a
// MediaDevices 'devicechange' event. One would think, innocently, that
// a 'devicechange' event means the devices have changed. But as of the
// time of writing, we are seeing mobile Safari firing spurious
// 'devicechange' events (where no change has actually occurred) when
// we call MediaDevices.getUserMedia. So, filter by deep equality.
distinctUntilChanged<MediaDeviceInfo[]>(deepCompare),
),
[kind, requestPermissions],
);
const available = useObservableEagerState(
useMemo(
() =>
deviceObserver$.pipe(
map((availableRaw) => {
// Sometimes browsers (particularly Firefox) can return multiple device
// entries for the exact same device ID; using a map deduplicates them
let available = new Map<string, DeviceLabel>(
availableRaw.map((d, i) => [
d.deviceId,
d.label
? { type: "name", name: d.label }
: { type: "number", number: i + 1 },
]),
);
// 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.
// We also create this if we do not have any available devices, so that
// we can use the default or the earpiece.
if (
kind === "audiooutput" &&
!available.has("") &&
!available.has("default") &&
available.size
)
available = new Map([
["", { type: "default", name: availableRaw[0]?.label || null }],
...available,
]);
// Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device.
return available;
}),
),
[deviceObserver$, kind],
),
);
const [preferredId, select] = useSetting(setting);
const selectedId = useSelectedId(available, preferredId);
const selectedGroupId = useObservableEagerState(
useMemo(
() =>
deviceObserver$.pipe(
map(
(availableRaw) =>
availableRaw.find((d) => d.deviceId === selectedId)?.groupId,
),
),
[deviceObserver$, selectedId],
),
);
return useMemo(
() => ({
available,
selectedId,
useAsEarpiece: false,
selectedGroupId,
select,
}),
[available, selectedId, selectedGroupId, select],
);
}
export const deviceStub: MediaDeviceHandle = {
available: new Map(),
selectedId: undefined,
selectedGroupId: undefined,
select: () => {},
useAsEarpiece: false,
};
export const devicesStub: MediaDevices = {
audioInput: deviceStub,
audioOutput: deviceStub,
videoInput: deviceStub,
startUsingDeviceNames: () => {},
stopUsingDeviceNames: () => {},
};
export const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
function useInputDevices(): InputDevices {
// Counts the number of callers currently using device names.
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
const usingNames = numCallersUsingNames > 0;
const audioInput = useMediaDeviceHandle(
"audioinput",
audioInputSetting,
usingNames,
);
const videoInput = useMediaDeviceHandle(
"videoinput",
videoInputSetting,
usingNames,
);
const startUsingDeviceNames = useCallback(
() => setNumCallersUsingNames((n) => n + 1),
[setNumCallersUsingNames],
);
const stopUsingDeviceNames = useCallback(
() => setNumCallersUsingNames((n) => n - 1),
[setNumCallersUsingNames],
);
return {
audioInput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
usingNames,
};
}
interface Props {
children: JSX.Element;
}
export const MediaDevicesProvider: FC<Props> = ({ children }) => {
const {
audioInput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
usingNames,
} = useInputDevices();
const { controlledAudioDevices } = useUrlParams();
const webViewAudioOutput = useMediaDeviceHandle(
"audiooutput",
audioOutputSetting,
usingNames,
);
const controlledAudioOutput = useControlledOutput();
const context: MediaDevices = useMemo(
() => ({
audioInput,
audioOutput: controlledAudioDevices
? controlledAudioOutput
: webViewAudioOutput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
}),
[
audioInput,
controlledAudioDevices,
controlledAudioOutput,
webViewAudioOutput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
],
);
return (
<MediaDevicesContext.Provider value={context}>
{children}
</MediaDevicesContext.Provider>
);
};
function useControlledOutput(): MediaDeviceHandle {
const { available } = useObservableEagerState(
useObservable(() => {
const outputDeviceData$ = availableOutputDevices$.pipe(
map((devices) => {
const deviceForEarpiece = devices.find((d) => d.forEarpiece);
const deviceMapTuple: [string, DeviceLabel][] = devices.map(
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
let deviceLabel: DeviceLabel = { type: "name", name };
// if (isExternalHeadset) // Do we want this?
if (isEarpiece) deviceLabel = { type: "earpiece" };
if (isSpeaker) deviceLabel = { type: "default", name };
return [id, deviceLabel];
},
);
return {
devicesMap: new Map<string, DeviceLabel>(deviceMapTuple),
deviceForEarpiece,
};
}),
);
return combineLatest(
[outputDeviceData$, iosDeviceMenu$],
({ devicesMap, deviceForEarpiece }, iosShowEarpiece) => {
let available = devicesMap;
if (iosShowEarpiece && !!deviceForEarpiece) {
available = new Map([
...devicesMap.entries(),
[EARPIECE_CONFIG_ID, { type: "earpiece" }],
]);
}
return { available, deviceForEarpiece };
},
);
}),
);
const [preferredId, setPreferredId] = useSetting(audioOutputSetting);
useEffect(() => {
const subscription = outputDevice$.subscribe((id) => {
if (id) setPreferredId(id);
});
return (): void => subscription.unsubscribe();
}, [setPreferredId]);
const selectedId = useSelectedId(available, preferredId);
const [asEarpiece, setAsEarpiece] = useState(false);
useEffect(() => {
// Let the hosting application know which output device has been selected.
// This information is probably only of interest if the earpiece mode has been
// selected - for example, Element X iOS listens to this to determine whether it
// should enable the proximity sensor.
if (selectedId) {
window.controls.onAudioDeviceSelect?.(selectedId);
// Call deprecated method for backwards compatibility.
window.controls.onOutputDeviceSelect?.(selectedId);
}
setAsEarpiece(selectedId === EARPIECE_CONFIG_ID);
}, [selectedId]);
return useMemo(
() => ({
available: available,
selectedId,
selectedGroupId: undefined,
select: setPreferredId,
useAsEarpiece: asEarpiece,
}),
[available, selectedId, setPreferredId, asEarpiece],
);
}
export const useMediaDevices = (): MediaDevices =>
useContext(MediaDevicesContext);
/**
* React hook that requests for the media devices context to be populated with
* real device names while this component is mounted. This is not done by
* default because it may involve requesting additional permissions from the
* user.
*/
export const useMediaDeviceNames = (
context: MediaDevices,
enabled = true,
): void =>
useEffect(() => {
if (enabled) {
context.startUsingDeviceNames();
return context.stopUsingDeviceNames;
}
}, [context, enabled]);
/**
* A convenience hook to get the audio node configuration for the earpiece.
* It will check the `useAsEarpiece` of the `audioOutput` device and return
* the appropriate pan and volume values.
*
* @returns pan and volume values for the earpiece audio node configuration.
*/
export const useEarpieceAudioConfig = (): {
pan: number;
volume: number;
} => {
const { audioOutput } = useMediaDevices();
// We use only the right speaker (pan = 1) for the earpiece.
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
const pan = useMemo(
() => (audioOutput.useAsEarpiece ? 1 : 0),
[audioOutput.useAsEarpiece],
);
// We also do lower the volume by a factor of 10 to optimize for the usecase where
// a user is holding the phone to their ear.
const volume = useMemo(
() => (audioOutput.useAsEarpiece ? 0.1 : 1),
[audioOutput.useAsEarpiece],
);
return { pan, volume };
};

View File

@@ -10,7 +10,14 @@ import {
supportsBackgroundProcessors,
type BackgroundOptions,
} from "@livekit/track-processors";
import { createContext, type FC, useContext, useEffect, useMemo } from "react";
import {
createContext,
type FC,
type JSX,
useContext,
useEffect,
useMemo,
} from "react";
import { type LocalVideoTrack } from "livekit-client";
import {

View File

@@ -19,12 +19,18 @@ import E2EEWorker from "livekit-client/e2ee-worker?worker";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { map } from "rxjs";
import {
map,
NEVER,
type Observable,
type Subscription,
switchMap,
} from "rxjs";
import { defaultLiveKitOptions } from "./options";
import { type SFUConfig } from "./openIDSFU";
import { type MuteStates } from "../room/MuteStates";
import { type MediaDeviceHandle, useMediaDevices } from "./MediaDevicesContext";
import { useMediaDevices } from "../MediaDevicesContext";
import {
type ECConnectionState,
useECConnectionState,
@@ -39,6 +45,8 @@ import {
import { observeTrackReference$ } from "../state/MediaViewModel";
import { useUrlParams } from "../UrlParams";
import { useInitial } from "../useInitial";
import { getValue } from "../utils/observable";
import { type SelectedDevice } from "../state/MediaDevices";
interface UseLivekitResult {
livekitRoom?: Room;
@@ -56,7 +64,9 @@ export function useLivekit(
const initialMuteStates = useInitial(() => muteStates);
const devices = useMediaDevices();
const initialDevices = useInitial(() => devices);
const initialAudioInputId = useInitial(
() => getValue(devices.audioInput.selected$)?.id,
);
// Store if audio/video are currently updating. If to prohibit unnecessary calls
// to setMicrophoneEnabled/setCameraEnabled
@@ -94,15 +104,20 @@ export function useLivekit(
...defaultLiveKitOptions,
videoCaptureDefaults: {
...defaultLiveKitOptions.videoCaptureDefaults,
deviceId: initialDevices.videoInput.selectedId,
deviceId: getValue(devices.videoInput.selected$)?.id,
processor,
},
audioCaptureDefaults: {
...defaultLiveKitOptions.audioCaptureDefaults,
deviceId: initialDevices.audioInput.selectedId,
deviceId: initialAudioInputId,
},
audioOutput: {
deviceId: initialDevices.audioOutput.selectedId,
// When using controlled audio devices, we don't want to set the
// deviceId here, because it will be set by the native app.
// (also the id does not need to match a browser device id)
deviceId: controlledAudioDevices
? undefined
: getValue(devices.audioOutput.selected$)?.id,
},
e2ee,
};
@@ -157,7 +172,7 @@ export function useLivekit(
);
const connectionState = useECConnectionState(
initialDevices.audioInput.selectedId,
initialAudioInputId,
initialMuteStates.audio.enabled,
room,
sfuConfig,
@@ -312,62 +327,65 @@ export function useLivekit(
) {
const syncDevice = (
kind: MediaDeviceKind,
device: MediaDeviceHandle,
): void => {
const id = device.selectedId;
// Detect if we're trying to use chrome's default device, in which case
// we need to to see if the default device has changed to a different device
// by comparing the group ID of the device we're using against the group ID
// of what the default device is *now*.
// This is special-cased for only audio inputs because we need to dig around
// in the LocalParticipant object for the track object and there's not a nice
// way to do that generically. There is usually no OS-level default video capture
// device anyway, and audio outputs work differently.
if (
id === "default" &&
kind === "audioinput" &&
room.options.audioCaptureDefaults?.deviceId === "default"
) {
const activeMicTrack = Array.from(
room.localParticipant.audioTrackPublications.values(),
).find((d) => d.source === Track.Source.Microphone)?.track;
selected$: Observable<SelectedDevice | undefined>,
): Subscription =>
selected$.subscribe((device) => {
if (
activeMicTrack &&
// only restart if the stream is still running: LiveKit will detect
// when a track stops & restart appropriately, so this is not our job.
// Plus, we need to avoid restarting again if the track is already in
// the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended" &&
device.selectedGroupId !==
activeMicTrack.mediaStreamTrack.getSettings().groupId
device !== undefined &&
room.getActiveDevice(kind) !== device.id
) {
// It's different, so restart the track, ie. cause Livekit to do another
// getUserMedia() call with deviceId: default to get the *new* default device.
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
// the deviceId hasn't changed (was & still is default).
room.localParticipant
.getTrackPublication(Track.Source.Microphone)
?.audioTrack?.restartTrack()
.catch((e) => {
logger.error(`Failed to restart audio device track`, e);
});
}
} else {
if (id !== undefined && room.getActiveDevice(kind) !== id) {
room
.switchActiveDevice(kind, id)
.switchActiveDevice(kind, device.id)
.catch((e) =>
logger.error(`Failed to sync ${kind} device with LiveKit`, e),
);
}
}
};
});
syncDevice("audioinput", devices.audioInput);
syncDevice("audiooutput", devices.audioOutput);
syncDevice("videoinput", devices.videoInput);
const subscriptions = [
syncDevice("audioinput", devices.audioInput.selected$),
syncDevice("audiooutput", devices.audioOutput.selected$),
syncDevice("videoinput", devices.videoInput.selected$),
// Restart the audio input track whenever we detect that the active media
// device has changed to refer to a different hardware device. We do this
// for the sake of Chrome, which provides a "default" device that is meant
// to match the system's default audio input, whatever that may be.
// This is special-cased for only audio inputs because we need to dig around
// in the LocalParticipant object for the track object and there's not a nice
// way to do that generically. There is usually no OS-level default video capture
// device anyway, and audio outputs work differently.
devices.audioInput.selected$
.pipe(switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER))
.subscribe(() => {
const activeMicTrack = Array.from(
room.localParticipant.audioTrackPublications.values(),
).find((d) => d.source === Track.Source.Microphone)?.track;
if (
activeMicTrack &&
// only restart if the stream is still running: LiveKit will detect
// when a track stops & restart appropriately, so this is not our job.
// Plus, we need to avoid restarting again if the track is already in
// the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended"
) {
// Restart the track, which will cause Livekit to do another
// getUserMedia() call with deviceId: default to get the *new* default device.
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
// the deviceId hasn't changed (was & still is default).
room.localParticipant
.getTrackPublication(Track.Source.Microphone)
?.audioTrack?.restartTrack()
.catch((e) => {
logger.error(`Failed to restart audio device track`, e);
});
}
}),
];
return (): void => {
for (const s of subscriptions) s.unsubscribe();
};
}
}, [room, devices, connectionState, controlledAudioDevices]);

View File

@@ -23,6 +23,7 @@ import {
import { App } from "./App";
import { init as initRageshake } from "./settings/rageshake";
import { Initializer } from "./initializer";
import { AppViewModel } from "./state/AppViewModel";
window.setLKLogLevel = setLKLogLevel;
@@ -60,7 +61,7 @@ Initializer.initBeforeReact()
.then(() => {
root.render(
<StrictMode>
<App />
<App vm={new AppViewModel()} />
</StrictMode>,
);
})

View File

@@ -32,6 +32,7 @@ import {
mockEmitter,
mockMatrixRoom,
mockMatrixRoomMember,
mockMediaDevices,
mockRtcMembership,
MockRTCSession,
} from "../utils/test";
@@ -40,6 +41,7 @@ import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCFocusMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
vi.mock("../soundUtils");
vi.mock("../useAudioContext");
@@ -147,20 +149,22 @@ function createGroupCallView(
const { getByText } = render(
<BrowserRouter>
<TooltipProvider>
<ProcessorProvider>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
isJoined={joined}
muteStates={muteState}
widget={widget}
/>
</ProcessorProvider>
<MediaDevicesContext.Provider value={mockMediaDevices({})}>
<ProcessorProvider>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
isJoined={joined}
muteStates={muteState}
widget={widget}
/>
</ProcessorProvider>
</MediaDevicesContext.Provider>
</TooltipProvider>
</BrowserRouter>,
);

View File

@@ -40,7 +40,7 @@ import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../utils/media";
import { ActiveCall } from "./InCallView";
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useMediaDevices } from "../MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
import {
@@ -197,8 +197,7 @@ export const GroupCallView: FC<Props> = ({
[memberships],
);
const deviceContext = useMediaDevices();
const latestDevices = useLatest(deviceContext);
const mediaDevices = useMediaDevices();
const latestMuteStates = useLatest(muteStates);
const enterRTCSessionOrError = useCallback(
@@ -250,7 +249,7 @@ export const GroupCallView: FC<Props> = ({
logger.debug(
`Found audio input ID ${deviceId} for name ${audioInput}`,
);
latestDevices.current!.audioInput.select(deviceId);
mediaDevices.audioInput.select(deviceId);
}
}
@@ -264,7 +263,7 @@ export const GroupCallView: FC<Props> = ({
logger.debug(
`Found video input ID ${deviceId} for name ${videoInput}`,
);
latestDevices.current!.videoInput.select(deviceId);
mediaDevices.videoInput.select(deviceId);
}
}
};
@@ -306,7 +305,7 @@ export const GroupCallView: FC<Props> = ({
preload,
skipLobby,
perParticipantE2EE,
latestDevices,
mediaDevices,
latestMuteStates,
enterRTCSessionOrError,
useNewMembershipManager,

View File

@@ -31,6 +31,7 @@ import {
mockLocalParticipant,
mockMatrixRoom,
mockMatrixRoomMember,
mockMediaDevices,
mockRemoteParticipant,
mockRtcMembership,
type MockRTCSession,
@@ -45,6 +46,7 @@ import {
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
// vi.hoisted(() => {
// localStorage = {} as unknown as Storage;
@@ -147,41 +149,43 @@ function createInCallView(): RenderResult & {
rtcSession.joined = true;
const renderResult = render(
<BrowserRouter>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession as unknown as MatrixRTCSession}
>
<TooltipProvider>
<RoomContext.Provider value={livekitRoom}>
<InCallView
client={client}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
muteStates={muteState}
vm={vm}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
livekitRoom={livekitRoom}
participantCount={0}
onLeave={function (): void {
throw new Error("Function not implemented.");
}}
connState={ConnectionState.Connected}
onShareClick={null}
/>
</RoomContext.Provider>
</TooltipProvider>
</ReactionsSenderProvider>
<MediaDevicesContext.Provider value={mockMediaDevices({})}>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession as unknown as MatrixRTCSession}
>
<TooltipProvider>
<RoomContext.Provider value={livekitRoom}>
<InCallView
client={client}
hideHeader={true}
rtcSession={rtcSession as unknown as MatrixRTCSession}
muteStates={muteState}
vm={vm}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
livekitRoom={livekitRoom}
participantCount={0}
onLeave={function (): void {
throw new Error("Function not implemented.");
}}
connState={ConnectionState.Connected}
onShareClick={null}
/>
</RoomContext.Provider>
</TooltipProvider>
</ReactionsSenderProvider>
</MediaDevicesContext.Provider>
</BrowserRouter>,
);
return {

View File

@@ -12,15 +12,14 @@ import { type MatrixClient } from "matrix-js-sdk";
import {
type FC,
type PointerEvent,
type PropsWithoutRef,
type TouchEvent,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type JSX,
type ReactNode,
} from "react";
import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
@@ -437,13 +436,14 @@ export const InCallView: FC<InCallViewProps> = ({
const Tile = useMemo(
() =>
forwardRef<
HTMLDivElement,
PropsWithoutRef<TileProps<TileViewModel, HTMLDivElement>>
>(function Tile(
{ className, style, targetWidth, targetHeight, model },
function Tile({
ref,
) {
className,
style,
targetWidth,
targetHeight,
model,
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
const spotlightExpanded = useObservableEagerState(
vm.spotlightExpanded$,
);
@@ -481,7 +481,7 @@ export const InCallView: FC<InCallViewProps> = ({
style={style}
/>
);
}),
},
[vm, openProfile],
);

View File

@@ -19,7 +19,7 @@ import {
LinkIcon,
CheckIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import useClipboard from "react-use-clipboard";
import copy from "copy-to-clipboard";
import { Modal } from "../Modal";
import { getAbsoluteRoomUrl } from "../utils/matrix";
@@ -42,18 +42,17 @@ export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
[e2eeSystem, room.name, room.roomId],
);
const [, setCopied] = useClipboard(url);
const [toastOpen, setToastOpen] = useState(false);
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
const onButtonClick = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
setCopied();
copy(url);
onDismiss();
setToastOpen(true);
},
[setCopied, onDismiss],
[url, onDismiss],
);
return (

View File

@@ -24,7 +24,7 @@ import {
type LocalVideoTrack,
Track,
} from "livekit-client";
import { useObservable } from "observable-hooks";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { map } from "rxjs";
import { useNavigate } from "react-router-dom";
@@ -45,7 +45,7 @@ import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useMediaDevices } from "../MediaDevicesContext";
import { useInitial } from "../useInitial";
import { useSwitchCamera as useShowSwitchCamera } from "./useSwitchCamera";
import {
@@ -54,6 +54,7 @@ import {
} from "../livekit/TrackProcessorContext";
import { usePageTitle } from "../usePageTitle";
import { useLatest } from "../useLatest";
import { getValue } from "../utils/observable";
interface Props {
client: MatrixClient;
@@ -126,13 +127,18 @@ export const LobbyView: FC<Props> = ({
);
const devices = useMediaDevices();
const videoInputId = useObservableEagerState(
devices.videoInput.selected$,
)?.id;
// Capture the audio options as they were when we first mounted, because
// we're not doing anything with the audio anyway so we don't need to
// re-open the devices when they change (see below).
const initialAudioOptions = useInitial(
() =>
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
muteStates.audio.enabled && {
deviceId: getValue(devices.audioInput.selected$)?.id,
},
);
const { processor } = useTrackProcessor();
@@ -148,14 +154,14 @@ export const LobbyView: FC<Props> = ({
// which would cause the devices to be re-opened on the next render.
audio: Object.assign({}, initialAudioOptions),
video: muteStates.video.enabled && {
deviceId: devices.videoInput.selectedId,
deviceId: videoInputId,
processor: initialProcessor,
},
}),
[
initialAudioOptions,
muteStates.video.enabled,
devices.videoInput.selectedId,
videoInputId,
initialProcessor,
],
);

View File

@@ -5,20 +5,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import {
afterAll,
afterEach,
describe,
expect,
it,
onTestFinished,
vi,
} from "vitest";
import { type FC, useCallback, useState } from "react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { of } from "rxjs";
import { useMuteStates } from "./MuteStates";
import {
type DeviceLabel,
type MediaDeviceHandle,
type MediaDevices,
MediaDevicesContext,
} from "../livekit/MediaDevicesContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { mockConfig } from "../utils/test";
import { MediaDevices } from "../state/MediaDevices";
import { ObservableScope } from "../state/ObservableScope";
vi.mock("@livekit/components-core");
interface TestComponentProps {
isJoined?: boolean;
@@ -73,16 +82,6 @@ const mockCamera: MediaDeviceInfo = {
},
};
function mockDevices(available: Map<string, DeviceLabel>): MediaDeviceHandle {
return {
available,
selectedId: "",
selectedGroupId: "",
select: (): void => {},
useAsEarpiece: false,
};
}
function mockMediaDevices(
{
microphone,
@@ -94,21 +93,21 @@ function mockMediaDevices(
camera?: boolean;
} = { microphone: true, speaker: true, camera: true },
): MediaDevices {
return {
audioInput: mockDevices(
microphone
? new Map([[mockMicrophone.deviceId, mockMicrophone]])
: new Map(),
),
audioOutput: mockDevices(
speaker ? new Map([[mockSpeaker.deviceId, mockSpeaker]]) : new Map(),
),
videoInput: mockDevices(
camera ? new Map([[mockCamera.deviceId, mockCamera]]) : new Map(),
),
startUsingDeviceNames: (): void => {},
stopUsingDeviceNames: (): void => {},
};
vi.mocked(createMediaDeviceObserver).mockImplementation((kind) => {
switch (kind) {
case "audioinput":
return of(microphone ? [mockMicrophone] : []);
case "audiooutput":
return of(speaker ? [mockSpeaker] : []);
case "videoinput":
return of(camera ? [mockCamera] : []);
case undefined:
throw new Error("Unimplemented");
}
});
const scope = new ObservableScope();
onTestFinished(() => scope.end());
return new MediaDevices(scope);
}
describe("useMuteStates", () => {
@@ -206,7 +205,12 @@ describe("useMuteStates", () => {
const user = userEvent.setup();
mockConfig();
const noDevices = mockMediaDevices({ microphone: false, camera: false });
// Warm up these Observables before making further changes to the
// createMediaDevicesObserver mock
noDevices.audioInput.available$.subscribe(() => {}).unsubscribe();
noDevices.videoInput.available$.subscribe(() => {}).unsubscribe();
const someDevices = mockMediaDevices();
const ReappearanceTest: FC = () => {
const [devices, setDevices] = useState(someDevices);
const onConnectDevicesClick = useCallback(

View File

@@ -14,11 +14,14 @@ import {
} from "react";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import {
type MediaDeviceHandle,
useMediaDevices,
} from "../livekit/MediaDevicesContext";
type DeviceLabel,
type SelectedDevice,
type MediaDevice,
} from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import { ElementWidgetActions, widget } from "../widget";
import { Config } from "../config/Config";
@@ -53,24 +56,24 @@ export interface MuteStates {
}
function useMuteState(
device: MediaDeviceHandle,
device: MediaDevice<DeviceLabel, SelectedDevice>,
enabledByDefault: () => boolean,
): MuteState {
const available = useObservableEagerState(device.available$);
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
// Determine the default value once devices are actually connected
(prev) =>
prev ?? (device.available.size > 0 ? enabledByDefault() : undefined),
[device.available.size],
(prev) => prev ?? (available.size > 0 ? enabledByDefault() : undefined),
[available.size],
);
return useMemo(
() =>
device.available.size === 0
available.size === 0
? deviceUnavailable
: {
enabled: enabled ?? false,
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
},
[device.available.size, enabled, setEnabled],
[available.size, enabled, setEnabled],
);
}

View File

@@ -46,7 +46,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<button
aria-disabled="false"
aria-labelledby=":r0:"
aria-labelledby="«r0»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -69,7 +69,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button>
<button
aria-disabled="false"
aria-labelledby=":r5:"
aria-labelledby="«r5»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -91,7 +91,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby=":ra:"
aria-labelledby="«ra»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="secondary"
data-size="lg"
@@ -112,7 +112,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby=":rf:"
aria-labelledby="«rf»"
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
data-kind="primary"
data-size="lg"
@@ -138,7 +138,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="toggle layout"
>
<input
aria-labelledby=":rk:"
aria-labelledby="«rk»"
name="layout"
type="radio"
value="spotlight"
@@ -156,7 +156,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby=":rp:"
aria-labelledby="«rp»"
checked=""
name="layout"
type="radio"

View File

@@ -22,7 +22,7 @@ import {
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { useMediaDevices } from "../livekit/MediaDevicesContext";
import { useMediaDevices } from "../MediaDevicesContext";
import { platform } from "../Platform";
import { useLatest } from "../useLatest";

View File

@@ -121,10 +121,18 @@ export async function enterRTCSession(
...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
delayedLeaveEventRestartMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_ms ??
matrixRtcSessionConfig?.membership_keep_alive_period,
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms ??
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
networkErrorRetryMs: matrixRtcSessionConfig?.membership_keep_alive_period,
makeKeyDelay: matrixRtcSessionConfig?.key_rotation_on_leave_delay,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay:
matrixRtcSessionConfig?.wait_for_key_rotation_ms ??
matrixRtcSessionConfig?.key_rotation_on_leave_delay,
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport,
},
);

View File

@@ -21,15 +21,18 @@ import {
Separator,
} from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { useObservableEagerState } from "observable-hooks";
import {
EARPIECE_CONFIG_ID,
type MediaDeviceHandle,
} from "../livekit/MediaDevicesContext";
type AudioOutputDeviceLabel,
type DeviceLabel,
type SelectedDevice,
type MediaDevice,
} from "../state/MediaDevices";
import styles from "./DeviceSelection.module.css";
interface Props {
device: MediaDeviceHandle;
device: MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
title: string;
numberedLabel: (number: number) => string;
}
@@ -41,6 +44,8 @@ export const DeviceSelection: FC<Props> = ({
}) => {
const { t } = useTranslation();
const groupId = useId();
const available = useObservableEagerState(device.available$);
const selectedId = useObservableEagerState(device.selected$)?.id;
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
device.select(e.target.value);
@@ -49,7 +54,7 @@ export const DeviceSelection: FC<Props> = ({
);
// There is no need to show the menu if there is no choice that can be made.
if (device.available.size <= 1) return null;
if (available.size <= 1) return null;
return (
<div className={styles.selection}>
@@ -64,7 +69,7 @@ export const DeviceSelection: FC<Props> = ({
</Heading>
<Separator className={styles.separator} />
<div className={styles.options}>
{[...device.available].map(([id, label]) => {
{[...available].map(([id, label]) => {
let labelText: ReactNode;
switch (label.type) {
case "name":
@@ -94,20 +99,13 @@ export const DeviceSelection: FC<Props> = ({
break;
}
let isSelected = false;
if (device.useAsEarpiece) {
isSelected = id === EARPIECE_CONFIG_ID;
} else {
isSelected = id === device.selectedId;
}
return (
<InlineField
key={id}
name={groupId}
control={
<RadioControl
checked={isSelected}
checked={id === selectedId}
onChange={onChange}
value={id}
/>

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 FC, type ReactNode, useState } from "react";
import { type FC, type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk";
import { Button, Root as Form, Separator } from "@vector-im/compound-web";
@@ -17,11 +17,8 @@ import styles from "./SettingsModal.module.css";
import { type Tab, TabContainer } from "../tabs/Tabs";
import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import {
useMediaDevices,
useMediaDeviceNames,
iosDeviceMenu$,
} from "../livekit/MediaDevicesContext";
import { iosDeviceMenu$ } from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
import { widget } from "../widget";
import {
useSetting,
@@ -98,7 +95,10 @@ export const SettingsModal: FC<Props> = ({
};
const devices = useMediaDevices();
useMediaDeviceNames(devices, open);
useEffect(() => {
if (open) devices.requestDeviceNames();
}, [open, devices]);
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
const [showDeveloperSettingsTab] = useSetting(developerMode);

19
src/state/AppViewModel.ts Normal file
View File

@@ -0,0 +1,19 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { MediaDevices } from "./MediaDevices";
import { ViewModel } from "./ViewModel";
/**
* The top-level state holder for the application.
*/
export class AppViewModel extends ViewModel {
public readonly mediaDevices = new MediaDevices(this.scope);
// TODO: Move more application logic here. The CallViewModel, at the very
// least, ought to be accessible from this object.
}

366
src/state/MediaDevices.ts Normal file
View File

@@ -0,0 +1,366 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
combineLatest,
filter,
map,
merge,
of,
pairwise,
startWith,
Subject,
switchMap,
type Observable,
} from "rxjs";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { logger } from "matrix-js-sdk/lib/logger";
import {
audioInput as audioInputSetting,
audioOutput as audioOutputSetting,
videoInput as videoInputSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
} from "../settings/settings";
import { type ObservableScope } from "./ObservableScope";
import {
outputDevice$ as controlledOutputSelection$,
availableOutputDevices$ as controlledAvailableOutputDevices$,
} from "../controls";
import { getUrlParams } from "../UrlParams";
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
const EARPIECE_CONFIG_ID = "earpiece-id";
export type DeviceLabel =
| { type: "name"; name: string }
| { type: "number"; number: number }
| { type: "default"; name: string | null };
export type AudioOutputDeviceLabel = DeviceLabel | { type: "earpiece" };
export interface SelectedDevice {
id: string;
}
export interface SelectedAudioInputDevice extends SelectedDevice {
/**
* Emits whenever we think that this audio input device has logically changed
* to refer to a different hardware device.
*/
hardwareDeviceChange$: Observable<void>;
}
export interface SelectedAudioOutputDevice extends SelectedDevice {
/**
* Whether this device is a "virtual earpiece" device. If so, we should output
* on a single channel of the device at a reduced volume.
*/
virtualEarpiece: boolean;
}
export interface MediaDevice<Label, Selected> {
/**
* A map from available device IDs to labels.
*/
available$: Observable<Map<string, Label>>;
/**
* The selected device.
*/
selected$: Observable<Selected | undefined>;
/**
* Selects a new device.
*/
select(id: string): void;
}
/**
* An observable that represents if we should display the devices menu for iOS.
* This implies the following
* - hide any input devices (they do not work anyhow on ios)
* - Show a button to show the native output picker instead.
* - Only show the earpiece toggle option if the earpiece is available:
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
*/
export const iosDeviceMenu$ = navigator.userAgent.includes("iPhone")
? of(true)
: alwaysShowIphoneEarpieceSetting.value$;
function availableRawDevices$(
kind: MediaDeviceKind,
usingNames$: Observable<boolean>,
scope: ObservableScope,
): Observable<MediaDeviceInfo[]> {
return usingNames$.pipe(
switchMap((usingNames) =>
createMediaDeviceObserver(
kind,
(e) => logger.error("Error creating MediaDeviceObserver", e),
usingNames,
),
),
startWith([]),
scope.state(),
);
}
function buildDeviceMap(
availableRaw: MediaDeviceInfo[],
): Map<string, DeviceLabel> {
return new Map<string, DeviceLabel>(
availableRaw.map((d, i) => [
d.deviceId,
d.label
? { type: "name", name: d.label }
: { type: "number", number: i + 1 },
]),
);
}
function selectDevice$<Label>(
available$: Observable<Map<string, Label>>,
preferredId$: Observable<string | undefined>,
): Observable<string | undefined> {
return combineLatest([available$, preferredId$], (available, preferredId) => {
if (available.size) {
// If the preferred device is available, use it. Or if every available
// device ID is falsy, the browser is probably just being paranoid about
// fingerprinting and we should still try using the preferred device.
// Worst case it is not available and the browser will gracefully fall
// back to some other device for us when requesting the media stream.
// Otherwise, select the first available device.
return (preferredId !== undefined && available.has(preferredId)) ||
(available.size === 1 && available.has(""))
? preferredId
: available.keys().next().value;
}
return undefined;
});
}
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
private readonly availableRaw$: Observable<MediaDeviceInfo[]> =
availableRawDevices$("audioinput", this.usingNames$, this.scope);
public readonly available$ = this.availableRaw$.pipe(
map(buildDeviceMap),
this.scope.state(),
);
public readonly selected$ = selectDevice$(
this.available$,
audioInputSetting.value$,
).pipe(
map((id) =>
id === undefined
? undefined
: {
id,
// We can identify when the hardware device has changed by watching for
// changes in the group ID
hardwareDeviceChange$: this.availableRaw$.pipe(
map((devices) => devices.find((d) => d.deviceId === id)?.groupId),
pairwise(),
filter(([before, after]) => before !== after),
map(() => undefined),
),
},
),
this.scope.state(),
);
public select(id: string): void {
audioInputSetting.setValue(id);
}
public constructor(
private readonly usingNames$: Observable<boolean>,
private readonly scope: ObservableScope,
) {}
}
class AudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{
public readonly available$ = availableRawDevices$(
"audiooutput",
this.usingNames$,
this.scope,
).pipe(
map((availableRaw) => {
const available = 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.
if (available.size && !available.has("") && !available.has("default"))
available.set("", {
type: "default",
name: availableRaw[0]?.label || null,
});
// Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device.
return available;
}),
this.scope.state(),
);
public readonly selected$ = selectDevice$(
this.available$,
audioOutputSetting.value$,
).pipe(
map((id) =>
id === undefined
? undefined
: {
id,
virtualEarpiece: false,
},
),
this.scope.state(),
);
public select(id: string): void {
audioOutputSetting.setValue(id);
}
public constructor(
private readonly usingNames$: Observable<boolean>,
private readonly scope: ObservableScope,
) {}
}
class ControlledAudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{
public readonly available$ = combineLatest(
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
(availableRaw, iosDeviceMenu) => {
const available = new Map<string, AudioOutputDeviceLabel>(
availableRaw.map(
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
let deviceLabel: AudioOutputDeviceLabel;
// if (isExternalHeadset) // Do we want this?
if (isEarpiece) deviceLabel = { type: "earpiece" };
else if (isSpeaker) deviceLabel = { type: "default", name };
else deviceLabel = { type: "name", name };
return [id, deviceLabel];
},
),
);
// Create a virtual earpiece device in case a non-earpiece device is
// designated for this purpose
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
return available;
},
).pipe(this.scope.state());
private readonly deviceSelection$ = new Subject<string>();
public select(id: string): void {
this.deviceSelection$.next(id);
}
public readonly selected$ = merge(
this.deviceSelection$,
controlledOutputSelection$,
).pipe(
startWith<string | undefined>(undefined),
map((id) =>
id === undefined
? undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID },
),
this.scope.state(),
);
public constructor(private readonly scope: ObservableScope) {
this.selected$.subscribe((device) => {
// Let the hosting application know which output device has been selected.
// This information is probably only of interest if the earpiece mode has
// been selected - for example, Element X iOS listens to this to determine
// whether it should enable the proximity sensor.
if (device !== undefined) {
logger.info("[controlled-output] setAudioDeviceSelect called:", device);
window.controls.onAudioDeviceSelect?.(device.id);
// Also invoke the deprecated callback for backward compatibility
window.controls.onOutputDeviceSelect?.(device.id);
}
});
}
}
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
public readonly available$ = availableRawDevices$(
"videoinput",
this.usingNames$,
this.scope,
).pipe(map(buildDeviceMap));
public readonly selected$ = selectDevice$(
this.available$,
videoInputSetting.value$,
).pipe(
map((id) => (id === undefined ? undefined : { id })),
this.scope.state(),
);
public select(id: string): void {
videoInputSetting.setValue(id);
}
public constructor(
private readonly usingNames$: Observable<boolean>,
private readonly scope: ObservableScope,
) {}
}
export class MediaDevices {
private readonly deviceNamesRequest$ = new Subject<void>();
/**
* Requests that the media devices be populated with the names of each
* available device, rather than numbered identifiers. This may invoke a
* permissions pop-up, so it should only be called when there is a clear user
* intent to view the device list.
*/
public requestDeviceNames(): void {
this.deviceNamesRequest$.next();
}
// Start using device names as soon as requested. This will cause LiveKit to
// briefly request device permissions and acquire media streams for each
// device type while calling `enumerateDevices`, which is what browsers want
// you to do to receive device names in lieu of a more explicit permissions
// API. This flag never resets to false, because once permissions are granted
// the first time, the user won't be prompted again until reload of the page.
private readonly usingNames$ = this.deviceNamesRequest$.pipe(
map(() => true),
startWith(false),
this.scope.state(),
);
public readonly audioInput: MediaDevice<
DeviceLabel,
SelectedAudioInputDevice
> = new AudioInput(this.usingNames$, this.scope);
public readonly audioOutput: MediaDevice<
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> = getUrlParams().controlledAudioDevices
? new ControlledAudioOutput(this.scope)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
new VideoInput(this.usingNames$, this.scope);
public constructor(private readonly scope: ObservableScope) {}
}

View File

@@ -430,8 +430,6 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
}
/**
},
},
* The local participant's user media.
*/
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {

View File

@@ -7,8 +7,9 @@ Please see LICENSE in the repository root for full details.
import {
type ComponentProps,
type FC,
type ReactNode,
forwardRef,
type Ref,
useCallback,
useRef,
useState,
@@ -50,6 +51,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { useReactionsSender } from "../reactions/useReactionsSender";
interface TileProps {
ref?: Ref<HTMLDivElement>;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
@@ -66,132 +68,128 @@ interface UserMediaTileProps extends TileProps {
menuEnd?: ReactNode;
}
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
(
{
vm,
showSpeakingIndicators,
locallyMuted,
menuStart,
menuEnd,
className,
displayName,
...props
const UserMediaTile: FC<UserMediaTileProps> = ({
ref,
vm,
showSpeakingIndicators,
locallyMuted,
menuStart,
menuEnd,
className,
displayName,
...props
}) => {
const { toggleRaisedHand } = useReactionsSender();
const { t } = useTranslation();
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const audioStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.audioStreamStats$);
const videoStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.videoStreamStats$);
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const speaking = useObservableEagerState(vm.speaking$);
const cropVideo = useObservableEagerState(vm.cropVideo$);
const onSelectFitContain = useCallback(
(e: Event) => {
e.preventDefault();
vm.toggleFitContain();
},
ref,
) => {
const { toggleRaisedHand } = useReactionsSender();
const { t } = useTranslation();
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const audioStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.audioStreamStats$);
const videoStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.videoStreamStats$);
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const speaking = useObservableEagerState(vm.speaking$);
const cropVideo = useObservableEagerState(vm.cropVideo$);
const onSelectFitContain = useCallback(
(e: Event) => {
e.preventDefault();
vm.toggleFitContain();
},
[vm],
);
const handRaised = useObservableState(vm.handRaised$);
const reaction = useObservableState(vm.reaction$);
[vm],
);
const handRaised = useObservableState(vm.handRaised$);
const reaction = useObservableState(vm.reaction$);
const AudioIcon = locallyMuted
? VolumeOffSolidIcon
: audioEnabled
? MicOnSolidIcon
: MicOffSolidIcon;
const audioIconLabel = locallyMuted
? t("video_tile.muted_for_me")
: audioEnabled
? t("microphone_on")
: t("microphone_off");
const AudioIcon = locallyMuted
? VolumeOffSolidIcon
: audioEnabled
? MicOnSolidIcon
: MicOffSolidIcon;
const audioIconLabel = locallyMuted
? t("video_tile.muted_for_me")
: audioEnabled
? t("microphone_on")
: t("microphone_off");
const [menuOpen, setMenuOpen] = useState(false);
const menu = (
<>
{menuStart}
<ToggleMenuItem
Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")}
checked={cropVideo}
onSelect={onSelectFitContain}
/>
{menuEnd}
</>
);
const raisedHandOnClick = vm.local
? (): void => void toggleRaisedHand()
: undefined;
const showSpeaking = showSpeakingIndicators && speaking;
const tile = (
<MediaView
ref={ref}
video={video}
member={vm.member}
unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus}
videoEnabled={videoEnabled}
videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeaking,
[styles.handRaised]: !showSpeaking && handRaised,
})}
nameTagLeadingIcon={
<AudioIcon
width={20}
height={20}
aria-label={audioIconLabel}
data-muted={locallyMuted || !audioEnabled}
className={styles.muteIcon}
/>
}
displayName={displayName}
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local}
audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats}
{...props}
const [menuOpen, setMenuOpen] = useState(false);
const menu = (
<>
{menuStart}
<ToggleMenuItem
Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")}
checked={cropVideo}
onSelect={onSelectFitContain}
/>
);
{menuEnd}
</>
);
return (
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
{menu}
</ContextMenu>
);
},
);
const raisedHandOnClick = vm.local
? (): void => void toggleRaisedHand()
: undefined;
const showSpeaking = showSpeakingIndicators && speaking;
const tile = (
<MediaView
ref={ref}
video={video}
member={vm.member}
unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus}
videoEnabled={videoEnabled}
videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeaking,
[styles.handRaised]: !showSpeaking && handRaised,
})}
nameTagLeadingIcon={
<AudioIcon
width={20}
height={20}
aria-label={audioIconLabel}
data-muted={locallyMuted || !audioEnabled}
className={styles.muteIcon}
/>
}
displayName={displayName}
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local}
audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats}
{...props}
/>
);
return (
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
{menu}
</ContextMenu>
);
};
UserMediaTile.displayName = "UserMediaTile";
@@ -200,48 +198,51 @@ interface LocalUserMediaTileProps extends TileProps {
onOpenProfile: (() => void) | null;
}
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
({ vm, onOpenProfile, ...props }, ref) => {
const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror$);
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
(e: Event) => {
e.preventDefault();
vm.setAlwaysShow(!latestAlwaysShow.current);
},
[vm, latestAlwaysShow],
);
const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
ref,
vm,
onOpenProfile,
...props
}) => {
const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror$);
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
(e: Event) => {
e.preventDefault();
vm.setAlwaysShow(!latestAlwaysShow.current);
},
[vm, latestAlwaysShow],
);
return (
<UserMediaTile
ref={ref}
vm={vm}
locallyMuted={false}
mirror={mirror}
menuStart={
<ToggleMenuItem
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onSelect={onSelectAlwaysShow}
return (
<UserMediaTile
ref={ref}
vm={vm}
locallyMuted={false}
mirror={mirror}
menuStart={
<ToggleMenuItem
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onSelect={onSelectAlwaysShow}
/>
}
menuEnd={
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
}
menuEnd={
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
)
}
{...props}
/>
);
},
);
)
}
{...props}
/>
);
};
LocalUserMediaTile.displayName = "LocalUserMediaTile";
@@ -249,10 +250,11 @@ interface RemoteUserMediaTileProps extends TileProps {
vm: RemoteUserMediaViewModel;
}
const RemoteUserMediaTile = forwardRef<
HTMLDivElement,
RemoteUserMediaTileProps
>(({ vm, ...props }, ref) => {
const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
ref,
vm,
...props
}) => {
const { t } = useTranslation();
const locallyMuted = useObservableEagerState(vm.locallyMuted$);
const localVolume = useObservableEagerState(vm.localVolume$);
@@ -303,11 +305,12 @@ const RemoteUserMediaTile = forwardRef<
{...props}
/>
);
});
};
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface GridTileProps {
ref?: Ref<HTMLDivElement>;
vm: GridTileViewModel;
onOpenProfile: (() => void) | null;
targetWidth: number;
@@ -317,34 +320,37 @@ interface GridTileProps {
showSpeakingIndicators: boolean;
}
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
({ vm, onOpenProfile, ...props }, theirRef) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const media = useObservableEagerState(vm.media$);
const displayName = useObservableEagerState(media.displayname$);
export const GridTile: FC<GridTileProps> = ({
ref: theirRef,
vm,
onOpenProfile,
...props
}) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const media = useObservableEagerState(vm.media$);
const displayName = useObservableEagerState(media.displayname$);
if (media instanceof LocalUserMediaViewModel) {
return (
<LocalUserMediaTile
ref={ref}
vm={media}
onOpenProfile={onOpenProfile}
displayName={displayName}
{...props}
/>
);
} else {
return (
<RemoteUserMediaTile
ref={ref}
vm={media}
displayName={displayName}
{...props}
/>
);
}
},
);
if (media instanceof LocalUserMediaViewModel) {
return (
<LocalUserMediaTile
ref={ref}
vm={media}
onOpenProfile={onOpenProfile}
displayName={displayName}
{...props}
/>
);
} else {
return (
<RemoteUserMediaTile
ref={ref}
vm={media}
displayName={displayName}
{...props}
/>
);
}
};
GridTile.displayName = "GridTile";

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { animated } from "@react-spring/web";
import { type RoomMember } from "matrix-js-sdk";
import { type ComponentProps, type ReactNode, forwardRef } from "react";
import { type FC, type ComponentProps, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { VideoTrack } from "@livekit/components-react";
@@ -47,97 +47,94 @@ interface Props extends ComponentProps<typeof animated.div> {
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
}
export const MediaView = forwardRef<HTMLDivElement, Props>(
(
{
className,
style,
targetWidth,
targetHeight,
video,
videoFit,
mirror,
member,
videoEnabled,
unencryptedWarning,
nameTagLeadingIcon,
displayName,
primaryButton,
encryptionStatus,
raisedHandTime,
currentReaction,
raisedHandOnClick,
localParticipant,
audioStreamStats,
videoStreamStats,
...props
},
ref,
) => {
const { t } = useTranslation();
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
export const MediaView: FC<Props> = ({
ref,
className,
style,
targetWidth,
targetHeight,
video,
videoFit,
mirror,
member,
videoEnabled,
unencryptedWarning,
nameTagLeadingIcon,
displayName,
primaryButton,
encryptionStatus,
raisedHandTime,
currentReaction,
raisedHandOnClick,
localParticipant,
audioStreamStats,
videoStreamStats,
...props
}) => {
const { t } = useTranslation();
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
return (
<animated.div
className={classNames(styles.media, className, {
[styles.mirror]: mirror,
})}
style={style}
ref={ref}
data-testid="videoTile"
data-video-fit={videoFit}
{...props}
>
<div className={styles.bg}>
<Avatar
id={member?.userId ?? displayName}
name={displayName}
size={avatarSize}
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
style={{ display: video && videoEnabled ? "none" : "initial" }}
return (
<animated.div
className={classNames(styles.media, className, {
[styles.mirror]: mirror,
})}
style={style}
ref={ref}
data-testid="videoTile"
data-video-fit={videoFit}
{...props}
>
<div className={styles.bg}>
<Avatar
id={member?.userId ?? displayName}
name={displayName}
size={avatarSize}
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
style={{ display: video && videoEnabled ? "none" : "initial" }}
/>
{video?.publication !== undefined && (
<VideoTrack
trackRef={video}
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
style={{ display: video && videoEnabled ? "block" : "none" }}
data-testid="video"
/>
{video?.publication !== undefined && (
<VideoTrack
trackRef={video}
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
style={{ display: video && videoEnabled ? "block" : "none" }}
data-testid="video"
)}
</div>
<div className={styles.fg}>
<div className={styles.reactions}>
<RaisedHandIndicator
raisedHandTime={raisedHandTime}
miniature={avatarSize < 96}
showTimer={handRaiseTimerVisible}
onClick={raisedHandOnClick}
/>
{currentReaction && (
<ReactionIndicator
miniature={avatarSize < 96}
emoji={currentReaction.emoji}
/>
)}
</div>
<div className={styles.fg}>
<div className={styles.reactions}>
<RaisedHandIndicator
raisedHandTime={raisedHandTime}
miniature={avatarSize < 96}
showTimer={handRaiseTimerVisible}
onClick={raisedHandOnClick}
/>
{currentReaction && (
<ReactionIndicator
miniature={avatarSize < 96}
emoji={currentReaction.emoji}
/>
)}
{!video && !localParticipant && (
<div className={styles.status}>
{t("video_tile.waiting_for_media")}
</div>
{!video && !localParticipant && (
<div className={styles.status}>
{t("video_tile.waiting_for_media")}
</div>
)}
{(audioStreamStats || videoStreamStats) && (
<RTCConnectionStats
audio={audioStreamStats}
video={videoStreamStats}
/>
)}
{/* TODO: Bring this back once encryption status is less broken */}
{/*encryptionStatus !== EncryptionStatus.Okay && (
)}
{(audioStreamStats || videoStreamStats) && (
<RTCConnectionStats
audio={audioStreamStats}
video={videoStreamStats}
/>
)}
{/* TODO: Bring this back once encryption status is less broken */}
{/*encryptionStatus !== EncryptionStatus.Okay && (
<div className={styles.status}>
<Text as="span" size="sm" weight="medium" className={styles.name}>
{encryptionStatus === EncryptionStatus.Connecting &&
@@ -151,38 +148,37 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
</Text>
</div>
)*/}
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text
as="span"
size="sm"
weight="medium"
className={styles.name}
data-testid="name_tag"
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text
as="span"
size="sm"
weight="medium"
className={styles.name}
data-testid="name_tag"
>
{displayName}
</Text>
{unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}
placement="bottom"
isTriggerInteractive={false}
>
{displayName}
</Text>
{unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}
placement="bottom"
isTriggerInteractive={false}
>
<ErrorSolidIcon
width={20}
height={20}
className={styles.errorIcon}
role="img"
aria-label={t("common.unencrypted")}
/>
</Tooltip>
)}
</div>
{primaryButton}
<ErrorSolidIcon
width={20}
height={20}
className={styles.errorIcon}
role="img"
aria-label={t("common.unencrypted")}
/>
</Tooltip>
)}
</div>
</animated.div>
);
},
);
{primaryButton}
</div>
</animated.div>
);
};
MediaView.displayName = "MediaView";

View File

@@ -7,8 +7,9 @@ Please see LICENSE in the repository root for full details.
import {
type ComponentProps,
type FC,
type Ref,
type RefAttributes,
forwardRef,
useCallback,
useEffect,
useRef,
@@ -44,6 +45,7 @@ import { useLatest } from "../useLatest";
import { type SpotlightTileViewModel } from "../state/TileViewModel";
interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>;
className?: string;
"data-id": string;
targetWidth: number;
@@ -67,13 +69,13 @@ interface SpotlightLocalUserMediaItemProps
vm: LocalUserMediaViewModel;
}
const SpotlightLocalUserMediaItem = forwardRef<
HTMLDivElement,
SpotlightLocalUserMediaItemProps
>(({ vm, ...props }, ref) => {
const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
vm,
...props
}) => {
const mirror = useObservableEagerState(vm.mirror$);
return <MediaView ref={ref} mirror={mirror} {...props} />;
});
return <MediaView mirror={mirror} {...props} />;
};
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
@@ -81,16 +83,15 @@ interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
vm: UserMediaViewModel;
}
const SpotlightUserMediaItem = forwardRef<
HTMLDivElement,
SpotlightUserMediaItemProps
>(({ vm, ...props }, ref) => {
const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
vm,
...props
}) => {
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const cropVideo = useObservableEagerState(vm.cropVideo$);
const baseProps: SpotlightUserMediaItemBaseProps &
RefAttributes<HTMLDivElement> = {
ref,
videoEnabled,
videoFit: cropVideo ? "cover" : "contain",
...props,
@@ -101,11 +102,12 @@ const SpotlightUserMediaItem = forwardRef<
) : (
<MediaView mirror={false} {...baseProps} />
);
});
};
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
interface SpotlightItemProps {
ref?: Ref<HTMLDivElement>;
vm: MediaViewModel;
targetWidth: number;
targetHeight: number;
@@ -117,71 +119,63 @@ interface SpotlightItemProps {
"aria-hidden"?: boolean;
}
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
(
{
vm,
targetWidth,
targetHeight,
intersectionObserver$,
snap,
"aria-hidden": ariaHidden,
},
theirRef,
) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const displayName = useObservableEagerState(vm.displayname$);
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const SpotlightItem: FC<SpotlightItemProps> = ({
ref: theirRef,
vm,
targetWidth,
targetHeight,
intersectionObserver$,
snap,
"aria-hidden": ariaHidden,
}) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const displayName = useObservableEagerState(vm.displayname$);
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
// Hook this item up to the intersection observer
useEffect(() => {
const element = ourRef.current!;
let prevIo: IntersectionObserver | null = null;
const subscription = intersectionObserver$.subscribe((io) => {
prevIo?.unobserve(element);
io.observe(element);
prevIo = io;
});
return (): void => {
subscription.unsubscribe();
prevIo?.unobserve(element);
};
}, [intersectionObserver$]);
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
ref,
"data-id": vm.id,
className: classNames(styles.item, { [styles.snap]: snap }),
targetWidth,
targetHeight,
video,
member: vm.member,
unencryptedWarning,
displayName,
encryptionStatus,
"aria-hidden": ariaHidden,
localParticipant: vm.local,
// Hook this item up to the intersection observer
useEffect(() => {
const element = ourRef.current!;
let prevIo: IntersectionObserver | null = null;
const subscription = intersectionObserver$.subscribe((io) => {
prevIo?.unobserve(element);
io.observe(element);
prevIo = io;
});
return (): void => {
subscription.unsubscribe();
prevIo?.unobserve(element);
};
}, [intersectionObserver$]);
return vm instanceof ScreenShareViewModel ? (
<MediaView
videoEnabled
videoFit="contain"
mirror={false}
{...baseProps}
/>
) : (
<SpotlightUserMediaItem vm={vm} {...baseProps} />
);
},
);
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
ref,
"data-id": vm.id,
className: classNames(styles.item, { [styles.snap]: snap }),
targetWidth,
targetHeight,
video,
member: vm.member,
unencryptedWarning,
displayName,
encryptionStatus,
"aria-hidden": ariaHidden,
localParticipant: vm.local,
};
return vm instanceof ScreenShareViewModel ? (
<MediaView videoEnabled videoFit="contain" mirror={false} {...baseProps} />
) : (
<SpotlightUserMediaItem vm={vm} {...baseProps} />
);
};
SpotlightItem.displayName = "SpotlightItem";
interface Props {
ref?: Ref<HTMLDivElement>;
vm: SpotlightTileViewModel;
expanded: boolean;
onToggleExpanded: (() => void) | null;
@@ -192,156 +186,148 @@ interface Props {
style?: ComponentProps<typeof animated.div>["style"];
}
export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
(
{
vm,
expanded,
onToggleExpanded,
targetWidth,
targetHeight,
showIndicators,
className,
style,
},
theirRef,
) => {
const { t } = useTranslation();
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const maximised = useObservableEagerState(vm.maximised$);
const media = useObservableEagerState(vm.media$);
const [visibleId, setVisibleId] = useState<string | undefined>(
media[0]?.id,
);
const latestMedia = useLatest(media);
const latestVisibleId = useLatest(visibleId);
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
const canGoBack = visibleIndex > 0;
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
export const SpotlightTile: FC<Props> = ({
ref: theirRef,
vm,
expanded,
onToggleExpanded,
targetWidth,
targetHeight,
showIndicators,
className,
style,
}) => {
const { t } = useTranslation();
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const maximised = useObservableEagerState(vm.maximised$);
const media = useObservableEagerState(vm.media$);
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
const latestMedia = useLatest(media);
const latestVisibleId = useLatest(visibleId);
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
const canGoBack = visibleIndex > 0;
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
// To keep track of which item is visible, we need an intersection observer
// hooked up to the root element and the items. Because the items will run
// their effects before their parent does, we need to do this dance with an
// Observable to actually give them the intersection observer.
const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>(
() =>
root$.pipe(
map(
(r) =>
new IntersectionObserver(
(entries) => {
const visible = entries.find((e) => e.isIntersecting);
if (visible !== undefined)
setVisibleId(visible.target.getAttribute("data-id")!);
},
{ root: r, threshold: 0.5 },
),
),
// To keep track of which item is visible, we need an intersection observer
// hooked up to the root element and the items. Because the items will run
// their effects before their parent does, we need to do this dance with an
// Observable to actually give them the intersection observer.
const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>(
() =>
root$.pipe(
map(
(r) =>
new IntersectionObserver(
(entries) => {
const visible = entries.find((e) => e.isIntersecting);
if (visible !== undefined)
setVisibleId(visible.target.getAttribute("data-id")!);
},
{ root: r, threshold: 0.5 },
),
),
),
);
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
(prev) =>
prev == null || prev === visibleId || media.every((vm) => vm.id !== prev)
? null
: prev,
[visibleId],
);
const onBackClick = useCallback(() => {
const media = latestMedia.current;
const visibleIndex = media.findIndex(
(vm) => vm.id === latestVisibleId.current,
);
if (visibleIndex > 0) setScrollToId(media[visibleIndex - 1].id);
}, [latestVisibleId, latestMedia, setScrollToId]);
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
(prev) =>
prev == null ||
prev === visibleId ||
media.every((vm) => vm.id !== prev)
? null
: prev,
[visibleId],
const onNextClick = useCallback(() => {
const media = latestMedia.current;
const visibleIndex = media.findIndex(
(vm) => vm.id === latestVisibleId.current,
);
if (visibleIndex !== -1 && visibleIndex !== media.length - 1)
setScrollToId(media[visibleIndex + 1].id);
}, [latestVisibleId, latestMedia, setScrollToId]);
const onBackClick = useCallback(() => {
const media = latestMedia.current;
const visibleIndex = media.findIndex(
(vm) => vm.id === latestVisibleId.current,
);
if (visibleIndex > 0) setScrollToId(media[visibleIndex - 1].id);
}, [latestVisibleId, latestMedia, setScrollToId]);
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
const onNextClick = useCallback(() => {
const media = latestMedia.current;
const visibleIndex = media.findIndex(
(vm) => vm.id === latestVisibleId.current,
);
if (visibleIndex !== -1 && visibleIndex !== media.length - 1)
setScrollToId(media[visibleIndex + 1].id);
}, [latestVisibleId, latestMedia, setScrollToId]);
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
return (
<animated.div
ref={ref}
className={classNames(className, styles.tile, {
[styles.maximised]: maximised,
})}
style={style}
>
{canGoBack && (
<button
className={classNames(styles.advance, styles.back)}
aria-label={t("common.back")}
onClick={onBackClick}
>
<ChevronLeftIcon aria-hidden width={24} height={24} />
</button>
)}
<div className={styles.contents}>
return (
<animated.div
ref={ref}
className={classNames(className, styles.tile, {
[styles.maximised]: maximised,
})}
style={style}
>
{canGoBack && (
<button
className={classNames(styles.advance, styles.back)}
aria-label={t("common.back")}
onClick={onBackClick}
>
<ChevronLeftIcon aria-hidden width={24} height={24} />
</button>
)}
<div className={styles.contents}>
{media.map((vm) => (
<SpotlightItem
key={vm.id}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
intersectionObserver$={intersectionObserver$}
// This is how we get the container to scroll to the right media
// when the previous/next buttons are clicked: we temporarily
// remove all scroll snap points except for just the one media
// that we want to bring into view
snap={scrollToId === null || scrollToId === vm.id}
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
/>
))}
</div>
{onToggleExpanded && (
<button
className={classNames(styles.expand)}
aria-label={
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
</button>
)}
{canGoToNext && (
<button
className={classNames(styles.advance, styles.next)}
aria-label={t("common.next")}
onClick={onNextClick}
>
<ChevronRightIcon aria-hidden width={24} height={24} />
</button>
)}
{!expanded && (
<div
className={classNames(styles.indicators, {
[styles.show]: showIndicators && media.length > 1,
})}
>
{media.map((vm) => (
<SpotlightItem
<div
key={vm.id}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
intersectionObserver$={intersectionObserver$}
// This is how we get the container to scroll to the right media
// when the previous/next buttons are clicked: we temporarily
// remove all scroll snap points except for just the one media
// that we want to bring into view
snap={scrollToId === null || scrollToId === vm.id}
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
className={styles.item}
data-visible={vm.id === visibleId}
/>
))}
</div>
{onToggleExpanded && (
<button
className={classNames(styles.expand)}
aria-label={
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
</button>
)}
{canGoToNext && (
<button
className={classNames(styles.advance, styles.next)}
aria-label={t("common.next")}
onClick={onNextClick}
>
<ChevronRightIcon aria-hidden width={24} height={24} />
</button>
)}
{!expanded && (
<div
className={classNames(styles.indicators, {
[styles.show]: showIndicators && media.length > 1,
})}
>
{media.map((vm) => (
<div
key={vm.id}
className={styles.item}
data-visible={vm.id === visibleId}
/>
))}
</div>
)}
</animated.div>
);
},
);
)}
</animated.div>
);
};
SpotlightTile.displayName = "SpotlightTile";

View File

@@ -10,10 +10,12 @@ import { type FC } from "react";
import { render } from "@testing-library/react";
import userEvent, { type UserEvent } from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom";
import { of } from "rxjs";
import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext";
import { MediaDevicesContext } from "./MediaDevicesContext";
import { useAudioContext } from "./useAudioContext";
import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings";
import { mockMediaDevices } from "./utils/test";
const staticSounds = Promise.resolve({
aSound: new ArrayBuffer(0),
@@ -102,13 +104,21 @@ afterEach(() => {
});
test("can play a single sound", async () => {
const { findByText } = render(<TestComponentWrapper />);
const { findByText } = render(
<MediaDevicesContext.Provider value={mockMediaDevices({})}>
<TestComponentWrapper />
</MediaDevicesContext.Provider>,
);
await user.click(await findByText("Valid sound"));
expect(testAudioContext.createBufferSource).toHaveBeenCalledOnce();
});
test("will ignore sounds that are not registered", async () => {
const { findByText } = render(<TestComponentWrapper />);
const { findByText } = render(
<MediaDevicesContext.Provider value={mockMediaDevices({})}>
<TestComponentWrapper />
</MediaDevicesContext.Provider>,
);
await user.click(await findByText("Invalid sound"));
expect(testAudioContext.createBufferSource).not.toHaveBeenCalled();
});
@@ -116,19 +126,13 @@ test("will ignore sounds that are not registered", async () => {
test("will use the correct device", () => {
render(
<MediaDevicesContext.Provider
value={{
audioInput: deviceStub,
value={mockMediaDevices({
audioOutput: {
selectedId: "chosen-device",
selectedGroupId: "",
available: new Map(),
available$: of(new Map<never, never>()),
selected$: of({ id: "chosen-device", virtualEarpiece: false }),
select: () => {},
useAsEarpiece: false,
},
videoInput: deviceStub,
startUsingDeviceNames: () => {},
stopUsingDeviceNames: () => {},
}}
})}
>
<TestComponentWrapper />
</MediaDevicesContext.Provider>,
@@ -139,7 +143,11 @@ test("will use the correct device", () => {
test("will use the correct volume level", async () => {
soundEffectVolumeSetting.setValue(0.33);
const { findByText } = render(<TestComponentWrapper />);
const { findByText } = render(
<MediaDevicesContext.Provider value={mockMediaDevices({})}>
<TestComponentWrapper />
</MediaDevicesContext.Provider>,
);
await user.click(await findByText("Valid sound"));
expect(testAudioContext.gain.gain.setValueAtTime).toHaveBeenCalledWith(
0.33,
@@ -151,19 +159,13 @@ test("will use the correct volume level", async () => {
test("will use the pan if earpiece is selected", async () => {
const { findByText } = render(
<MediaDevicesContext.Provider
value={{
audioInput: deviceStub,
value={mockMediaDevices({
audioOutput: {
selectedId: "chosen-device",
selectedGroupId: "",
available: new Map(),
available$: of(new Map<never, never>()),
selected$: of({ id: "chosen-device", virtualEarpiece: true }),
select: () => {},
useAsEarpiece: true,
},
videoInput: deviceStub,
startUsingDeviceNames: () => {},
stopUsingDeviceNames: () => {},
}}
})}
>
<TestComponentWrapper />
</MediaDevicesContext.Provider>,

View File

@@ -7,17 +7,16 @@ Please see LICENSE in the repository root for full details.
import { logger } from "matrix-js-sdk/lib/logger";
import { useState, useEffect } from "react";
import { useObservableEagerState } from "observable-hooks";
import {
soundEffectVolume as soundEffectVolumeSetting,
useSetting,
} from "./settings/settings";
import {
useEarpieceAudioConfig,
useMediaDevices,
} from "./livekit/MediaDevicesContext";
import { useEarpieceAudioConfig, useMediaDevices } from "./MediaDevicesContext";
import { type PrefetchedSounds } from "./soundUtils";
import { useUrlParams } from "./UrlParams";
import * as controls from "./controls";
/**
* Play a sound though a given AudioContext. Will take
@@ -42,6 +41,7 @@ async function playSound(
src.buffer = buffer;
src.connect(gain).connect(pan).connect(ctx.destination);
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
controls.setPlaybackStarted();
src.start();
return p;
}
@@ -71,8 +71,6 @@ export function useAudioContext<S extends string>(
props: Props<S>,
): UseAudioContext<S> | null {
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
const { audioOutput } = useMediaDevices();
const { controlledAudioDevices } = useUrlParams();
const [audioContext, setAudioContext] = useState<AudioContext>();
const [audioBuffers, setAudioBuffers] = useState<Record<S, AudioBuffer>>();
@@ -109,6 +107,11 @@ export function useAudioContext<S extends string>(
};
}, [props.sounds, props.latencyHint]);
const audioOutputId = useObservableEagerState(
useMediaDevices().audioOutput.selected$,
)?.id;
const { controlledAudioDevices } = useUrlParams();
// Update the sink ID whenever we change devices.
useEffect(() => {
if (
@@ -118,11 +121,11 @@ export function useAudioContext<S extends string>(
) {
// https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/setSinkId
// @ts-expect-error - setSinkId doesn't exist yet in types, maybe because it's not supported everywhere.
audioContext.setSinkId(audioOutput.selectedId).catch((ex) => {
audioContext.setSinkId(audioOutputId).catch((ex) => {
logger.warn("Unable to change sink for audio context", ex);
});
}
}, [audioContext, audioOutput.selectedId, controlledAudioDevices]);
}, [audioContext, audioOutputId, controlledAudioDevices]);
const { pan: earpiecePan, volume: earpieceVolume } = useEarpieceAudioConfig();
// Don't return a function until we're ready.

View File

@@ -5,21 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MutableRefObject, type RefCallback, useCallback } from "react";
import { type RefCallback, type RefObject, useCallback } from "react";
/**
* Combines multiple refs into one, useful for attaching multiple refs to the
* same DOM node.
*/
export const useMergedRefs = <T>(
...refs: (MutableRefObject<T | null> | RefCallback<T | null> | null)[]
...refs: (RefObject<T | null> | RefCallback<T | null> | null | undefined)[]
): RefCallback<T | null> =>
useCallback(
(value) =>
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(value);
} else if (ref !== null) {
} else if (ref) {
ref.current = value;
}
}),

View File

@@ -38,3 +38,18 @@ export function accumulate<State, Event>(
return (events$: Observable<Event>): Observable<State> =>
events$.pipe(scan(update, initial), startWith(initial));
}
/**
* Reads the current value of a state Observable without reacting to future
* changes.
*
* This function exists to help with certain cases of bridging Observables into
* React, where an initial value is needed. You should never use it to create an
* Observable derived from another Observable; use reactive operators instead.
*/
export function getValue<T>(state$: Observable<T>): T {
let value: T | typeof nothing = nothing;
state$.subscribe((x) => (value = x)).unsubscribe();
if (value === nothing) throw new Error("Not a state Observable");
return value;
}

View File

@@ -46,6 +46,7 @@ import {
type ResolvedConfigOptions,
} from "../config/ConfigOptions";
import { Config } from "../config/Config";
import { type MediaDevices } from "../state/MediaDevices";
export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers();
@@ -332,3 +333,18 @@ export const mockTrack = (identity: string): TrackReference =>
track: {},
source: {},
}) as unknown as TrackReference;
export const deviceStub = {
available$: of(new Map<never, never>()),
selected$: of(undefined),
select(): void {},
};
export function mockMediaDevices(data: Partial<MediaDevices>): MediaDevices {
return {
audioInput: deviceStub,
audioOutput: deviceStub,
videoInput: deviceStub,
...data,
} as MediaDevices;
}

View File

@@ -18,9 +18,9 @@ export default defineConfig((env) =>
output: "./config.json",
data: {
matrix_rtc_session: {
key_rotation_on_leave_delay: 15000,
membership_keep_alive_period: 5000,
membership_server_side_expiry_timeout: 15000,
wait_for_key_rotation_ms: 5000,
delayed_leave_event_restart_ms: 4000,
delayed_leave_event_delay_ms: 18000,
},
},
},

219
yarn.lock
View File

@@ -2717,8 +2717,8 @@ __metadata:
linkType: hard
"@livekit/components-react@npm:^2.0.0":
version: 2.9.9
resolution: "@livekit/components-react@npm:2.9.9"
version: 2.9.10
resolution: "@livekit/components-react@npm:2.9.10"
dependencies:
"@livekit/components-core": "npm:0.12.7"
clsx: "npm:2.1.1"
@@ -2732,7 +2732,7 @@ __metadata:
peerDependenciesMeta:
"@livekit/krisp-noise-filter":
optional: true
checksum: 10c0/7b3dad637b0bfd91c5636b1bb2c72ecde6136a4222d552690a3b3088c6fe4a28fbc30d2e8fec643c7e90316d8419393e7636001d5d192f5f1c9254df1307df0d
checksum: 10c0/86fc78d7b2d97540c43592435a65ac38b43fca9923af000aa90d884bd35794e18d202b7d915f44b63087abea5c94c37d6980772ed722226904262b2cddbec107
languageName: node
linkType: hard
@@ -2743,12 +2743,12 @@ __metadata:
languageName: node
linkType: hard
"@livekit/protocol@npm:1.38.0":
version: 1.38.0
resolution: "@livekit/protocol@npm:1.38.0"
"@livekit/protocol@npm:1.39.2":
version: 1.39.2
resolution: "@livekit/protocol@npm:1.39.2"
dependencies:
"@bufbuild/protobuf": "npm:^1.10.0"
checksum: 10c0/ca64d4f984853054ff60574730b08a761afcd3bdc084e5218663e54b0e7f395aa2022d9d15d982fa094bbc0179cb19ef6a96ec74b1aa3265d118a85d1a4fde33
checksum: 10c0/ce5f3ee3ab10ea1578fb40c1d16224879e48143a1bbbfb5e36a080fa5468892eaccfe21aad1166b5443f67e2aa54236facafd4b3235e41b331e91ca405379368
languageName: node
linkType: hard
@@ -2780,13 +2780,6 @@ __metadata:
languageName: node
linkType: hard
"@matrix-org/olm@npm:3.2.15":
version: 3.2.15
resolution: "@matrix-org/olm@npm:3.2.15"
checksum: 10c0/82a40d6e4e632a90670d4f15e8962272e302f4b9deed4fc78455c5ca78422c13bde6b53ebfc406630336926c9574386c9d9069c9c023db1c3d143117985c1e50
languageName: node
linkType: hard
"@mediapipe/tasks-vision@npm:^0.10.18":
version: 0.10.21
resolution: "@mediapipe/tasks-vision@npm:0.10.21"
@@ -4391,69 +4384,69 @@ __metadata:
languageName: node
linkType: hard
"@react-spring/animated@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/animated@npm:9.7.5"
"@react-spring/animated@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/animated@npm:10.0.1"
dependencies:
"@react-spring/shared": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
"@react-spring/shared": "npm:~10.0.1"
"@react-spring/types": "npm:~10.0.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/f8c2473c60f39a878c7dd0fdfcfcdbc720521e1506aa3f63c9de64780694a0a73d5ccc535a5ccec3520ddb70a71cf43b038b32c18e99531522da5388c510ecd7
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/aaccd4a8b0280ac846d463b253ad8f092ee4afc9dbedc8e77616adf5399ffec755344f09fdd8487cadaf815840dff84d354d1143579c27c2fcd6937549b5fc40
languageName: node
linkType: hard
"@react-spring/core@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/core@npm:9.7.5"
"@react-spring/core@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/core@npm:10.0.1"
dependencies:
"@react-spring/animated": "npm:~9.7.5"
"@react-spring/shared": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
"@react-spring/animated": "npm:~10.0.1"
"@react-spring/shared": "npm:~10.0.1"
"@react-spring/types": "npm:~10.0.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/5bfd83dfe248cd91889f215f015d908c7714ef445740fd5afa054b27ebc7d5a456abf6c309e2459d9b5b436e78d6fda16b62b9601f96352e9130552c02270830
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/059b122dda4138e5e7e461abd49350921e326735ca9a1d8aa19b1fdbae0937661b5f71af6fe82fd8f59e8db5549627849b38cc3f7ef2ec7ee9c93c3d6225174f
languageName: node
linkType: hard
"@react-spring/rafz@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/rafz@npm:9.7.5"
checksum: 10c0/8bdad180feaa9a0e870a513043a5e98a4e9b7292a9f887575b7e6fadab2677825bc894b7ff16c38511b35bfe6cc1072df5851c5fee64448d67551559578ca759
"@react-spring/rafz@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/rafz@npm:10.0.1"
checksum: 10c0/cba76f143d3a06f79dd0c09f7aefd17df9cca9b2c1ef7f9103255e5351326f4a42a5a1366f731a78f74380d96ba683bcc2a49312ed1e4b9e9e249e72c9ff68cb
languageName: node
linkType: hard
"@react-spring/shared@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/shared@npm:9.7.5"
"@react-spring/shared@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/shared@npm:10.0.1"
dependencies:
"@react-spring/rafz": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
"@react-spring/rafz": "npm:~10.0.1"
"@react-spring/types": "npm:~10.0.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/0207eacccdedd918a2fc55e78356ce937f445ce27ad9abd5d3accba8f9701a39349b55115641dc2b39bb9d3a155b058c185b411d292dc8cc5686bfa56f73b94f
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/f056aaa018b3744afd8244e8eea24534d32f92fad9ace815b80e159b296fb5db148e2c9bd840ad9a5188e7a3c0778fd564b8af9ae02cd500e019a228398fb3cf
languageName: node
linkType: hard
"@react-spring/types@npm:~9.7.5":
version: 9.7.5
resolution: "@react-spring/types@npm:9.7.5"
checksum: 10c0/85c05121853cacb64f7cf63a4855e9044635e1231f70371cd7b8c78bc10be6f4dd7c68f592f92a2607e8bb68051540989b4677a2ccb525dba937f5cd95dc8bc1
"@react-spring/types@npm:~10.0.1":
version: 10.0.1
resolution: "@react-spring/types@npm:10.0.1"
checksum: 10c0/260890f9c156dc69b77c846510017156d8c0a07cce70edc7c108e57b0cf4122b26a15e724b191481a51b2c914296de9e81d56618b2c339339d4b221930691baa
languageName: node
linkType: hard
"@react-spring/web@npm:^9.4.4":
version: 9.7.5
resolution: "@react-spring/web@npm:9.7.5"
"@react-spring/web@npm:^10.0.0":
version: 10.0.1
resolution: "@react-spring/web@npm:10.0.1"
dependencies:
"@react-spring/animated": "npm:~9.7.5"
"@react-spring/core": "npm:~9.7.5"
"@react-spring/shared": "npm:~9.7.5"
"@react-spring/types": "npm:~9.7.5"
"@react-spring/animated": "npm:~10.0.1"
"@react-spring/core": "npm:~10.0.1"
"@react-spring/shared": "npm:~10.0.1"
"@react-spring/types": "npm:~10.0.1"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: 10c0/bcd1e052e1b16341a12a19bf4515f153ca09d1fa86ff7752a5d02d7c4db58e8baf80e6283e64411f1e388c65340dce2254b013083426806b5dbae38bd151e53e
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/a0c788c9fd881ccb834feb22fc0694e74e59f7b76e498f0096f5b65e2c9812513955bf45ee27d7c5348f56a7bba7c5a6961d4be663728bb2172fe5aa6b6bdfc4
languageName: node
linkType: hard
@@ -5224,13 +5217,6 @@ __metadata:
languageName: node
linkType: hard
"@types/prop-types@npm:*":
version: 15.7.14
resolution: "@types/prop-types@npm:15.7.14"
checksum: 10c0/1ec775160bfab90b67a782d735952158c7e702ca4502968aa82565bd8e452c2de8601c8dfe349733073c31179116cf7340710160d3836aa8a1ef76d1532893b1
languageName: node
linkType: hard
"@types/qrcode@npm:^1.5.5":
version: 1.5.5
resolution: "@types/qrcode@npm:1.5.5"
@@ -5240,22 +5226,21 @@ __metadata:
languageName: node
linkType: hard
"@types/react-dom@npm:^18.3.0":
version: 18.3.7
resolution: "@types/react-dom@npm:18.3.7"
"@types/react-dom@npm:^19.0.0":
version: 19.1.5
resolution: "@types/react-dom@npm:19.1.5"
peerDependencies:
"@types/react": ^18.0.0
checksum: 10c0/8bd309e2c3d1604a28a736a24f96cbadf6c05d5288cfef8883b74f4054c961b6b3a5e997fd5686e492be903c8f3380dba5ec017eff3906b1256529cd2d39603e
"@types/react": ^19.0.0
checksum: 10c0/2a29e77cf6bb6e9f57bcfa54509c216cad2e16e244f0bd56369966ec88c072b9c91f6011d14f9e18fbfe2b801b18b86f616de75e5c8aef0be73c1f74abb33b49
languageName: node
linkType: hard
"@types/react@npm:^18.3.0":
version: 18.3.23
resolution: "@types/react@npm:18.3.23"
"@types/react@npm:^19.0.0":
version: 19.1.6
resolution: "@types/react@npm:19.1.6"
dependencies:
"@types/prop-types": "npm:*"
csstype: "npm:^3.0.2"
checksum: 10c0/49331800b76572eb2992a5c44801dbf8c612a5f99c8f4e4200f06c7de6f3a6e9455c661784a6c5469df96fa45622cb4a9d0982c44e6a0d5719be5f2ef1f545ed
checksum: 10c0/8b10b198e28997b3c57559750f8bcf5ae7b33c554b16b6f4fe2ece1d4de6a2fc8cb53e7effe08ec9cb939d2f479eb97c5e08aac2cf83b10a90164fe451cc8ea2
languageName: node
linkType: hard
@@ -5611,8 +5596,8 @@ __metadata:
linkType: hard
"@vector-im/compound-design-tokens@npm:^4.0.0":
version: 4.0.3
resolution: "@vector-im/compound-design-tokens@npm:4.0.3"
version: 4.0.4
resolution: "@vector-im/compound-design-tokens@npm:4.0.4"
peerDependencies:
"@types/react": "*"
react: ^17 || ^18 || ^19.0.0
@@ -5621,13 +5606,13 @@ __metadata:
optional: true
react:
optional: true
checksum: 10c0/4e32e46b4f0afef463ab7827c7bd1e0369bf83bab2cf86f182a5052b409c8b64e6fea84f818eee4d3f10e51be996e550d2b2dc664d7444f6685502f517d9a754
checksum: 10c0/e6ff6a956082f4a288237e7c7e60044319d7195cad0d5175dad7115270119f80c43252520db8f1a514b762f92dd5b7059c1217d7ccbe81daf71c426cbfeaf3dd
languageName: node
linkType: hard
"@vector-im/compound-web@npm:^7.12.0":
version: 7.12.0
resolution: "@vector-im/compound-web@npm:7.12.0"
"@vector-im/compound-web@npm:^8.0.0":
version: 8.1.2
resolution: "@vector-im/compound-web@npm:8.1.2"
dependencies:
"@floating-ui/react": "npm:^0.27.0"
"@radix-ui/react-context-menu": "npm:^2.2.1"
@@ -5647,7 +5632,7 @@ __metadata:
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 10c0/e5546a5b8ca1a5e718f31acf9a515af4b76877d5526f2b7f5d8fb1fa9382a9a375fc0dd7fc6d3f6e9fc2e2bb0109ca99fc2a63552fc5c33bfa86e33f96204931
checksum: 10c0/4d16794f20b5577bb32442a8261ccad87d83ef91d9212e4ff12af90c4423790859b9a712764211c751079d384bf4dc88522961e008a9bd9f46653fdd75cdc195
languageName: node
linkType: hard
@@ -6867,7 +6852,7 @@ __metadata:
languageName: node
linkType: hard
"copy-to-clipboard@npm:^3.3.1":
"copy-to-clipboard@npm:^3.3.3":
version: 3.3.3
resolution: "copy-to-clipboard@npm:3.3.3"
dependencies:
@@ -7457,7 +7442,7 @@ __metadata:
"@radix-ui/react-dialog": "npm:^1.0.4"
"@radix-ui/react-slider": "npm:^1.1.2"
"@radix-ui/react-visually-hidden": "npm:^1.0.3"
"@react-spring/web": "npm:^9.4.4"
"@react-spring/web": "npm:^10.0.0"
"@sentry/react": "npm:^8.0.0"
"@sentry/vite-plugin": "npm:^3.0.0"
"@stylistic/eslint-plugin": "npm:^3.0.0"
@@ -7473,19 +7458,20 @@ __metadata:
"@types/node": "npm:^22.0.0"
"@types/pako": "npm:^2.0.3"
"@types/qrcode": "npm:^1.5.5"
"@types/react": "npm:^18.3.0"
"@types/react-dom": "npm:^18.3.0"
"@types/react": "npm:^19.0.0"
"@types/react-dom": "npm:^19.0.0"
"@types/sdp-transform": "npm:^2.4.5"
"@types/uuid": "npm:10"
"@typescript-eslint/eslint-plugin": "npm:^8.31.0"
"@typescript-eslint/parser": "npm:^8.31.0"
"@use-gesture/react": "npm:^10.2.11"
"@vector-im/compound-design-tokens": "npm:^4.0.0"
"@vector-im/compound-web": "npm:^7.12.0"
"@vector-im/compound-web": "npm:^8.0.0"
"@vitejs/plugin-react": "npm:^4.0.1"
"@vitest/coverage-v8": "npm:^3.0.0"
babel-plugin-transform-vite-meta-env: "npm:^1.0.3"
classnames: "npm:^2.3.1"
copy-to-clipboard: "npm:^3.3.3"
eslint: "npm:^8.14.0"
eslint-config-google: "npm:^0.14.0"
eslint-config-prettier: "npm:^10.0.0"
@@ -7516,11 +7502,10 @@ __metadata:
posthog-js: "npm:1.160.3"
prettier: "npm:^3.0.0"
qrcode: "npm:^1.5.4"
react: "npm:18"
react-dom: "npm:18"
react: "npm:19"
react-dom: "npm:19"
react-i18next: "npm:^15.0.0"
react-router-dom: "npm:^7.0.0"
react-use-clipboard: "npm:^1.0.7"
react-use-measure: "npm:^2.1.1"
rxjs: "npm:^7.8.1"
sass: "npm:^1.42.1"
@@ -9922,11 +9907,11 @@ __metadata:
linkType: hard
"livekit-client@npm:^2.13.0":
version: 2.13.3
resolution: "livekit-client@npm:2.13.3"
version: 2.13.8
resolution: "livekit-client@npm:2.13.8"
dependencies:
"@livekit/mutex": "npm:1.1.1"
"@livekit/protocol": "npm:1.38.0"
"@livekit/protocol": "npm:1.39.2"
events: "npm:^3.3.0"
loglevel: "npm:^1.9.2"
sdp-transform: "npm:^2.15.0"
@@ -9936,7 +9921,7 @@ __metadata:
webrtc-adapter: "npm:^9.0.1"
peerDependencies:
"@types/dom-mediacapture-record": ^1
checksum: 10c0/9eadb9835514551c8e834cebb607999e632bc4b3b3e5d5e206db90b66e2e5467b8a1f76a03c648e00cc96c87256b4eece58dbb2b92dfcafa84f4798bbb305060
checksum: 10c0/bf1ba10891ab5beb44a30016c3923a635d0bb637d604280686b98ea366794985eec0a4a294fb1478a8a68ac90ba8ab0ea5c1e64986603d8ce53019399994385b
languageName: node
linkType: hard
@@ -10007,7 +9992,7 @@ __metadata:
languageName: node
linkType: hard
"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
"loose-envify@npm:^1.4.0":
version: 1.4.0
resolution: "loose-envify@npm:1.4.0"
dependencies:
@@ -10148,12 +10133,11 @@ __metadata:
linkType: hard
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
version: 37.6.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=bf6dc16ad32d47f2c6e167236f4c853ceef01d4f"
version: 37.8.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=adaf92162377414df620e004d8c58c67118490ca"
dependencies:
"@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^14.2.0"
"@matrix-org/olm": "npm:3.2.15"
another-json: "npm:^0.2.0"
bs58: "npm:^6.0.0"
content-type: "npm:^1.0.4"
@@ -10166,7 +10150,7 @@ __metadata:
sdp-transform: "npm:^2.14.1"
unhomoglyph: "npm:^1.0.6"
uuid: "npm:11"
checksum: 10c0/2877e7c5b2779200b48f3152bb7b510e58899b1e779e7e6d2bc1a236ed178fa8858f0547b644a2029f48f55dc7cc3954f48e2598c8963d45293c8280ccc23039
checksum: 10c0/8e842056ff926c615d53a4a333c9e54d0fbe7f88b97b55629464e67850cc512606accde6a8724dd48ac82714483f685515913b5556e9824c4d63c443317be1ef
languageName: node
linkType: hard
@@ -11577,15 +11561,14 @@ __metadata:
languageName: node
linkType: hard
"react-dom@npm:18":
version: 18.3.1
resolution: "react-dom@npm:18.3.1"
"react-dom@npm:19":
version: 19.1.0
resolution: "react-dom@npm:19.1.0"
dependencies:
loose-envify: "npm:^1.1.0"
scheduler: "npm:^0.23.2"
scheduler: "npm:^0.26.0"
peerDependencies:
react: ^18.3.1
checksum: 10c0/a752496c1941f958f2e8ac56239172296fcddce1365ce45222d04a1947e0cc5547df3e8447f855a81d6d39f008d7c32eab43db3712077f09e3f67c4874973e85
react: ^19.1.0
checksum: 10c0/3e26e89bb6c67c9a6aa86cb888c7a7f8258f2e347a6d2a15299c17eb16e04c19194e3452bc3255bd34000a61e45e2cb51e46292392340432f133e5a5d2dfb5fc
languageName: node
linkType: hard
@@ -11729,18 +11712,6 @@ __metadata:
languageName: node
linkType: hard
"react-use-clipboard@npm:^1.0.7":
version: 1.0.9
resolution: "react-use-clipboard@npm:1.0.9"
dependencies:
copy-to-clipboard: "npm:^3.3.1"
peerDependencies:
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
checksum: 10c0/f75ba85b885ae45d0c99a09a0652a2f64c1d9d6d015293bf01b2e6a9f50ff2a6b1830f6e60c1f639b9ae33fa434bb31adbf43f613df2402438204ea7456cf86a
languageName: node
linkType: hard
"react-use-measure@npm:^2.1.1":
version: 2.1.7
resolution: "react-use-measure@npm:2.1.7"
@@ -11754,12 +11725,10 @@ __metadata:
languageName: node
linkType: hard
"react@npm:18":
version: 18.3.1
resolution: "react@npm:18.3.1"
dependencies:
loose-envify: "npm:^1.1.0"
checksum: 10c0/283e8c5efcf37802c9d1ce767f302dd569dd97a70d9bb8c7be79a789b9902451e0d16334b05d73299b20f048cbc3c7d288bbbde10b701fa194e2089c237dbea3
"react@npm:19":
version: 19.1.0
resolution: "react@npm:19.1.0"
checksum: 10c0/530fb9a62237d54137a13d2cfb67a7db6a2156faed43eecc423f4713d9b20c6f2728b026b45e28fcd72e8eadb9e9ed4b089e99f5e295d2f0ad3134251bdd3698
languageName: node
linkType: hard
@@ -12352,12 +12321,10 @@ __metadata:
languageName: node
linkType: hard
"scheduler@npm:^0.23.2":
version: 0.23.2
resolution: "scheduler@npm:0.23.2"
dependencies:
loose-envify: "npm:^1.1.0"
checksum: 10c0/26383305e249651d4c58e6705d5f8425f153211aef95f15161c151f7b8de885f24751b377e4a0b3dd42cce09aad3f87a61dab7636859c0d89b7daf1a1e2a5c78
"scheduler@npm:^0.26.0":
version: 0.26.0
resolution: "scheduler@npm:0.26.0"
checksum: 10c0/5b8d5bfddaae3513410eda54f2268e98a376a429931921a81b5c3a2873aab7ca4d775a8caac5498f8cbc7d0daeab947cf923dbd8e215d61671f9f4e392d34356
languageName: node
linkType: hard