diff --git a/.github/workflows/publish-embedded-packages.yaml b/.github/workflows/publish-embedded-packages.yaml index 88d59947..977d2043 100644 --- a/.github/workflows/publish-embedded-packages.yaml +++ b/.github/workflows/publish-embedded-packages.yaml @@ -97,7 +97,7 @@ jobs: run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256 - name: Upload if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: files: | ${{ env.FILENAME_PREFIX }}.tar.gz @@ -297,7 +297,7 @@ jobs: NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }} - name: Add release notes if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: append_body: true body: | diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 40b94e26..58e84975 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -42,7 +42,7 @@ jobs: - name: Create Checksum run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256 - name: Upload - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: files: | ${{ env.FILENAME_PREFIX }}.tar.gz @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Add release note - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: append_body: true body: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d8af6f92..2cec64f5 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -2,11 +2,15 @@ name: Test on: pull_request: {} push: - branches: [livekit, full-mesh] + branches: [livekit] jobs: vitest: name: Run unit tests runs-on: ubuntu-latest + container: + # Make sure to grab the latest version of the Playwright image + # https://playwright.dev/docs/docker#pull-the-image + image: mcr.microsoft.com/playwright:v1.59.1-noble steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index d3b6e969..104e073e 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -20,4 +20,4 @@ jobs: persist-credentials: false - name: Run zizmor 🌈 - uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 + uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 diff --git a/.storybook/main.ts b/.storybook/main.ts index 977eca73..e227ef76 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -9,7 +9,28 @@ import type { StorybookConfig } from "@storybook/react-vite"; const config: StorybookConfig = { stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], - addons: ["@storybook/addon-docs"], + addons: ["@storybook/addon-docs", "@storybook/addon-vitest"], framework: "@storybook/react-vite", + // THIS IS IMPORTANT + // vitest runs without Vite's normal dependency optimization, so we need to manually include the polyfills for the stories to work. + // otherwise we will get: new dependencies optimized: ... + // and + // ``` + // [vitest] Vite unexpectedly reloaded a test. This may cause tests to fail, lead to flaky behaviour or duplicated test runs. + // For a stable experience, please add mentioned dependencies to your config's `optimizeDeps.include` field manually. + // ``` + // which breaks the storybook ci on the first and only run. + viteFinal(config) { + config.optimizeDeps = { + ...config.optimizeDeps, + include: [ + ...(config.optimizeDeps?.include ?? []), + "vite-plugin-node-polyfills/shims/buffer", + "vite-plugin-node-polyfills/shims/global", + "vite-plugin-node-polyfills/shims/process", + ], + }; + return config; + }, }; export default config; diff --git a/README.md b/README.md index 0c82e4b0..5df2c42e 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ requiring a separate Matrix client. ### πŸ“² In-App Calling (Widget Mode in Messenger Apps) -When used as a widget 🧩, Element Call is solely responsible on the core calling +When used as a widget 🧩, Element Call is solely responsible for the core calling functionality (MatrixRTC). Authentication, event handling, and room state updates (via the Client-Server API) are handled by the hosting client. Communication between Element Call and the client is managed through the widget @@ -118,8 +118,8 @@ For operating and deploying Element Call on your own server, refer to the ## 🧭 MatrixRTC Backend Discovery and Selection For proper Element Call operation each site deployment needs a MatrixRTC backend -setup as outlined in the [Self-Hosting](#self_hosting). A typical federated site -deployment for three different sites A, B and C is depicted below. +setup as outlined in the [Self-Hosting Guide](./docs/self_hosting.md). A typical +federated site deployment for three different sites A, B and C is depicted below.

Element Call federated setup @@ -127,7 +127,7 @@ deployment for three different sites A, B and C is depicted below. ### Backend Discovery -MatrixRTC backend (according to +The MatrixRTC backend (according to [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)) is announced by the Matrix site's `.well-known/matrix/client` file and discovered via the `org.matrix.msc4143.rtc_foci` key, e.g.: @@ -151,11 +151,10 @@ via `livekit_service_url`. - Each call participant proposes their discovered MatrixRTC backend from `org.matrix.msc4143.rtc_foci` in their `org.matrix.msc3401.call.member` state event. -- For **LiveKit** MatrixRTC backend +- For the **LiveKit** MatrixRTC backend ([MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)), - the **first participant who joined the call** defines via the `foci_preferred` - key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend - will be used for this call. + the **first participant who joined the call** defines which backend will be used for this call via + the `foci_preferred` key in their `org.matrix.msc3401.call.member` state event. - During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)** provides the client with the **LiveKit SFU WebSocket URL** and an **access JWT token** in order to exchange media via WebRTC. @@ -178,6 +177,13 @@ discuss and coordinate translation efforts. ## πŸ› οΈ Development +### Dependencies + +- Node.js (e.g. via [nvm](https://github.com/nvm-sh/nvm)) +- [Corepack](https://github.com/nodejs/corepack) (not bundled with Node.js anymore starting from 25.0.0) +- Docker client and runtime + Docker Compose (for the backend) + - On macOS you can install everything with `brew install colima docker docker-compose` + ### Frontend To get started clone and set up this project: @@ -202,7 +208,7 @@ pnpm dev See also: -- [Developing with linked packages](./linking.md) +- [Developing with linked packages](./docs/linking.md) ### Backend @@ -210,28 +216,29 @@ A docker compose file `dev-backend-docker-compose.yml` is provided to start the whole stack of components which is required for a local development environment including federation: -- Minimum Synapse Setup (servernameis: `synapse.m.localhost`, `synapse.othersite.m.localhost`) -- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy) +- Minimum Synapse Setup (servernames: `synapse.m.localhost`, `synapse.othersite.m.localhost`) +- MatrixRTC Authorization Service (Note: requires Federation API and hence a TLS reverse proxy) - Minimum LiveKit SFU setup using dev defaults for config - Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS) - Hostnames: `m.localhost`, `*.m.localhost`, `*.othersite.m.localhost` - - Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browsers trusted + - Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browser's trusted certificates - Minimum TLS reverse proxy for - Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost` - MatrixRTC backend: `matrix-rtc.m.localhost` and `matrix-rtc.othersite.m.localhost` - - Local Element Call development `call.m.localhost` via `yarn dev --host ` + - Local Element Call development `call.m.localhost` via `pnpm dev --host ` - Element Web `app.m.localhost` and `app.othersite.m.localhost` - Note certificates will expire on Thr, 20 September 2035 14:27:35 CEST These use a test 'secret' published in this repository, so this must be used only for local development and **_never be exposed to the public Internet._** -Run backend components: +Make sure your Docker runtime is running (e.g. via `colima start`) and then start +the backend components: ```sh pnpm backend -# or for podman-compose +# or for podman-compose: # podman-compose -f dev-backend-docker-compose.yml up ``` @@ -242,7 +249,7 @@ pnpm backend > `https://synapse.m.localhost/.well-known/matrix/client`. This can be either > done by adding the minimum localhost CA > ([./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt)) to your web -> browsers trusted certificates or by simply copying and pasting each URL into +> browser's trusted certificates or by simply copying and pasting each URL into > your browser’s address bar and follow the prompts to add the exception. ### Playwright tests diff --git a/docs/controls.md b/docs/controls.md index e5e0746d..b97fe795 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -12,7 +12,7 @@ A few aspects of Element Call's interface can be controlled through a global API On mobile platforms (iOS, Android), web views do not reliably support selecting audio output devices such as the main speaker, earpiece, or headset. To address this limitation, the following functions allow the hosting application (e.g., Element Web, Element X) to manage audio devices via exposed JavaScript interfaces. These functions must be enabled using the URL parameter `controlledAudioDevices` to take effect. -- `controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?, boolean; }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only. +- `controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?: boolean }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only. It flags the device that should be used if the user selects earpiece mode. This should be the main stereo loudspeaker of the device. - `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output. - `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example. diff --git a/docs/embedded_standalone.md b/docs/embedded_standalone.md index 24ad2a7d..456ce120 100644 --- a/docs/embedded_standalone.md +++ b/docs/embedded_standalone.md @@ -25,7 +25,7 @@ The basics are: 1. Add the appropriate platform dependency as given for a [release](https://github.com/element-hq/element-call/releases), or use the embedded tarball. e.g. `npm install @element-hq/element-call-embedded@0.9.0` 2. Include the assets from the platform dependency in the build process. e.g. copy the assets during a [Webpack](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/webpack.config.js#L677-L682) build. -3. Use the `index.html` entrypointof the imported assets when you are constructing the WebView or iframe. e.g. using a [relative path in a webapp](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/src/models/Call.ts#L680), or on the the Android [WebViewAssetLoader](https://github.com/element-hq/element-x-android/blob/fe5aab6588ecdcf9354a3bfbd9e97c1b31175a8f/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt#L20) +3. Use the `index.html` entrypoint of the imported assets when you are constructing the WebView or iframe. e.g. using a [relative path in a webapp](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/src/models/Call.ts#L680), or on the the Android [WebViewAssetLoader](https://github.com/element-hq/element-x-android/blob/fe5aab6588ecdcf9354a3bfbd9e97c1b31175a8f/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt#L20) 4. Set any of the [embedded-only URL parameters](./url_params.md#embedded-only-parameters) that you need. ## Widget vs standalone mode @@ -35,5 +35,5 @@ Element Call is developed using the [js-sdk](https://github.com/matrix-org/matri As a widget, the app only uses the core calling (MatrixRTC) parts. The rest (authentication, sending events, getting room state updates about calls) is done by the hosting client. Element Call and the hosting client are connected via the widget API. -Element Call detects that it is run as a widget if a widgetId is defined in the url parameters. If `widgetId` is present then Element Call will try to connect to the client via the widget postMessage API using the parameters provided in [Url Format and parameters +Element Call detects that it is run as a widget if `widgetId` is defined in the url parameters. If `widgetId` is present then Element Call will try to connect to the client via the widget postMessage API using the parameters provided in [Url Format and parameters ](./url_params.md). diff --git a/docs/linking.md b/docs/linking.md index 1016fffb..3a18844d 100644 --- a/docs/linking.md +++ b/docs/linking.md @@ -1,6 +1,6 @@ ## Quickstart guide -run +Run: ```bash ./scripts/setup-linking.sh @@ -50,7 +50,7 @@ before committing a change. To make this less of a foot gun we added a git hook. A `pre-commit` hook will check if linking is currently used. If it detects a `.pnpmfile.cjs` file it will abort the commit with an explanatory message. -You will than need to run `pnpm links:off` and commit again. +You will then need to run `pnpm links:off` and commit again. To activate the hooks configure git with (when using the setup script (`./scripts/setup-linking.sh`) this is already done): diff --git a/docs/linking_concept_reasoning.md b/docs/linking_concept_reasoning.md index 7c135a96..d065ba0b 100644 --- a/docs/linking_concept_reasoning.md +++ b/docs/linking_concept_reasoning.md @@ -11,7 +11,7 @@ When the renovate bot creates a PR it runs `pnpm install --ignore-pnpmfile`. Thi This breaks builds that **don't** ignore the `.pnpmfile.cjs`-file. (CI that runs on the renovate PR) From here we have two possible paths: -- ignore `.pnpmfile.cjs` in all CI builds CI will also fail if we accidently add it locally. +- ignore `.pnpmfile.cjs` in all CI builds (CI will also fail if we accidently add it locally). - fixup the `pnpm-lock.yaml` in the renovate PR to contain the correct `pnpmfileChecksum`. Ignoring in all CI builds means that CI will always fail if we enable the linking system. @@ -22,9 +22,9 @@ Only if we remember setting it back/disbale linking (or let ourselves remember b #### Summary - We will always run into conflicts with the `pnpmfileChecksum` because in renovate prs it will be empty (`--ignore-pnpmfile`) -- To keep it simple we set `--ignore-pnpmfile` in all of ours CI to see issues immediately. +- To keep it simple we set `--ignore-pnpmfile` in all of our CI builds to see issues immediately. - The only solution is to never have a `.pnpmfile.cjs` in the repository when pushing. - This way there will never be a commit with `pnpmfileChecksum` in the lockfile. - - renovate (which uses `--ignore-pnpmfile` which we cannot disable) and other CI will work + - renovate (which uses `--ignore-pnpmfile` which we cannot disable) and other CI will work. - We are able to use the linking system locally if we `cp` this file from the scripts folder into `./` on demand. - `pnpm links:on` and `pnpm links:off` + `./scripts/setup-linking.sh` will help us with this. diff --git a/docs/self_hosting.md b/docs/self_hosting.md index dc1dd687..e8ea2f6d 100644 --- a/docs/self_hosting.md +++ b/docs/self_hosting.md @@ -58,7 +58,7 @@ rc_message: rc_delayed_event_mgmt: # This needs to match at least the heart-beat frequency plus a bit of headroom - # Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s + # Currently the heart-beat is every 5 seconds which translates into a rate of 0.2Hz per_second: 1 burst_count: 20 ``` @@ -70,7 +70,7 @@ make sure that your Synapse server has either a `federation` or `openid` ### MatrixRTC Backend -In order to **guarantee smooth operation** of Element Call MatrixRTC backend is +In order to **guarantee smooth operation** of Element Call, a MatrixRTC backend is required for each site deployment. ![MSC4195 compatible setup](MSC4195_setup.drawio.png) @@ -190,8 +190,8 @@ backend mxrtc_auth_backend > [!IMPORTANT] > As defined in -> [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143) -> MatrixRTC backend must be announced to the client via your **Matrix site's +> [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143), +> the MatrixRTC backend(s) must be announced to the client via your **Matrix site's > `.well-known/matrix/client`** file (e.g. > `example.com/.well-known/matrix/client` matching the site deployment example > from above). The configuration is a list of Foci configs: @@ -222,7 +222,7 @@ Access-Control-Allow-Headers: X-Requested-With, Content-Type, Authorization > [!NOTE] > Most `org.matrix.msc4143.rtc_foci` configurations will only have one entry in -> the array +> the array. ## Building Element Call @@ -291,7 +291,7 @@ be able to handle those yet and it may behave unreliably. Therefore, to use a self-hosted homeserver, this is recommended to be a new server where any user account created has not joined any normal rooms anywhere -in the Matrix federated network. The homeserver used can be setup to disable +in the Matrix federated network. The homeserver used can be set up to disable federation, so as to prevent spam registrations (if you keep registrations open) and to ensure Element Call continues to work in case any user decides to log in to their Element Call account using the standard Element app and joins normal diff --git a/docs/url_params.md b/docs/url_params.md index e88e7095..4d567e84 100644 --- a/docs/url_params.md +++ b/docs/url_params.md @@ -12,7 +12,7 @@ https://element_call.domain/room/# ``` The URL is split into two sections. The `https://element_call.domain/room/#` -contains the app and the intend that the link brings you into a specific room +contains the app and the intent that the link brings you into a specific room (`https://call.element.io/#` would be the homepage). The fragment is used for query parameters to make sure they never get sent to the element_call.domain server. Here we have the actual Matrix room ID and the password which are used @@ -76,16 +76,16 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st These parameters are only supported in [widget](./embedded_standalone.md) mode. -| Name | Values | Required | Description | -| --------------- | ----------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseUrl` | | Yes | The base URL of the homeserver to use for media lookups. | -| `deviceId` | Matrix device ID | Yes | The Matrix device ID for the widget host. | -| `parentUrl` | | Yes | The url used to send widget action postMessages. This should be the domain of the client or the webview the widget is hosted in. (in case the widget is not in an Iframe but in a dedicated webview we send the postMessages same WebView the widget lives in. Filtering is done in the widget so it ignores the messages it receives from itself) | -| `posthogUserId` | Posthog user identifier | No | This replaces the `analyticsID` parameter | -| `preload` | `true` or `false` | No, defaults to `false` | Pauses app before joining a call until an `io.element.join` widget action is seen, allowing preloading. | -| `returnToLobby` | `true` or `false` | No, defaults to `false` | Displays the lobby in widget mode after leaving a call; shows a blank page if set to `false`. Useful for video rooms. | -| `userId` | [Matrix User Identifier](https://spec.matrix.org/v1.12/appendices/#user-identifiers) | Yes | The Matrix user ID. | -| `widgetId` | [MSC2774](https://github.com/matrix-org/matrix-spec-proposals/pull/2774) format widget ID | Yes | The id used by the widget. The presence of this parameter implies that element call will not connect to a homeserver directly and instead tries to establish postMessage communication via the `parentUrl`. | +| Name | Values | Required | Description | +| --------------- | ----------------------------------------------------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseUrl` | | Yes | The base URL of the homeserver to use for media lookups. | +| `deviceId` | Matrix device ID | Yes | The Matrix device ID for the widget host. | +| `parentUrl` | | Yes | The url used to send widget action postMessages. This should be the domain of the client or the webview the widget is hosted in. (In case the widget is not in an Iframe but in a dedicated webview, we send the postMessages in the same WebView the widget lives in. Filtering is done in the widget so it ignores the messages it receives from itself.) | +| `posthogUserId` | Posthog user identifier | No | This replaces the `analyticsID` parameter | +| `preload` | `true` or `false` | No, defaults to `false` | Pauses app before joining a call until an `io.element.join` widget action is seen, allowing preloading. | +| `returnToLobby` | `true` or `false` | No, defaults to `false` | Displays the lobby in widget mode after leaving a call; shows a blank page if set to `false`. Useful for video rooms. | +| `userId` | [Matrix User Identifier](https://spec.matrix.org/v1.12/appendices/#user-identifiers) | Yes | The Matrix user ID. | +| `widgetId` | [MSC2774](https://github.com/matrix-org/matrix-spec-proposals/pull/2774) format widget ID | Yes | The id used by the widget. The presence of this parameter implies that element call will not connect to a homeserver directly and instead tries to establish postMessage communication via the `parentUrl`. | ### Embedded-only parameters diff --git a/locales/en/app.json b/locales/en/app.json index b51c6ed9..a14663e9 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -3,6 +3,7 @@ "user_menu": "User menu" }, "action": { + "blur_background": "Blur background", "close": "Close", "copy_link": "Copy link", "edit": "Edit", diff --git a/package.json b/package.json index fef415eb..705cdc11 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "i18n": "i18next", "i18n:check": "i18next --fail-on-warnings --fail-on-update", "test": "vitest", + "test:storybook": "vitest --project=storybook", + "test:unit": "vitest --project=unit", "test:coverage": "vitest --coverage", "backend": "docker-compose -f dev-backend-docker-compose.yml up", "backend-playwright": "docker-compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up", @@ -39,7 +41,7 @@ }, "devDependencies": { "@babel/core": "^7.16.5", - "@babel/preset-env": "^7.22.20", + "@babel/preset-env": "^7.29.5", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.23.0", "@codecov/vite-plugin": "^1.3.0", @@ -52,15 +54,16 @@ "@livekit/protocol": "^1.42.2", "@livekit/track-processors": "^0.7.1", "@mediapipe/tasks-vision": "^0.10.18", - "@playwright/test": "^1.57.0", + "@playwright/test": "^1.59.0", "@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": "^10.0.0", "@sentry/react": "^8.0.0", "@sentry/vite-plugin": "^3.0.0", - "@storybook/addon-docs": "^10.3.3", - "@storybook/react-vite": "^10.3.3", + "@storybook/addon-docs": "^10.3.6", + "@storybook/addon-vitest": "^10.3.6", + "@storybook/react-vite": "^10.3.6", "@stylistic/eslint-plugin": "^3.0.0", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "^6.6.3", @@ -83,7 +86,9 @@ "@vector-im/compound-design-tokens": "^10.0.0", "@vector-im/compound-web": "^9.3.0", "@vitejs/plugin-react": "^4.0.1", + "@vitest/browser-playwright": "^4.1.5", "@vitest/coverage-v8": "^4.0.18", + "@vitest/ui": "4.1.5", "babel-plugin-transform-vite-meta-env": "^1.0.3", "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.3", @@ -98,7 +103,7 @@ "eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-rxjs": "^5.0.3", - "eslint-plugin-storybook": "^10.3.3", + "eslint-plugin-storybook": "^10.3.6", "eslint-plugin-unicorn": "^56.0.0", "fetch-mock": "11.1.5", "global-jsdom": "^26.0.0", @@ -118,7 +123,7 @@ "pako": "^2.0.4", "postcss": "^8.4.41", "postcss-preset-env": "^10.0.0", - "posthog-js": "1.160.3", + "posthog-js": "1.374.0", "prettier": "^3.0.0", "qrcode": "^1.5.4", "react": "19", @@ -128,7 +133,7 @@ "react-use-measure": "^2.1.1", "rxjs": "^7.8.1", "sass": "^1.42.1", - "storybook": "^10.3.3", + "storybook": "^10.3.6", "typescript": "^5.8.3", "typescript-eslint-language-service": "^5.0.5", "unique-names-generator": "^4.6.0", @@ -141,7 +146,7 @@ "vite-plugin-node-stdlib-browser": "^0.2.1", "vite-plugin-svgr": "^4.0.0", "vite-plugin-wasm": "^3.6.0", - "vitest": "^4.0.18", + "vitest": "^4.1.5", "vitest-axe": "^1.0.0-pre.3" }, "pnpm": { diff --git a/playwright.config.ts b/playwright.config.ts index 84afed64..85e65e13 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -79,6 +79,11 @@ export default defineConfig({ firefoxUserPrefs: { "permissions.default.microphone": 1, "permissions.default.camera": 1, + // Equivalent to Chromium's --use-fake-device-for-media-stream: + // feeds a synthetic media stream so getUserMedia and + // enumerateDevices work on CI runners without real hardware. + "media.navigator.streams.fake": true, + "media.navigator.permission.disabled": true, }, }, }, diff --git a/playwright/create-call.spec.ts b/playwright/create-call.spec.ts index b71f39ad..1a483f07 100644 --- a/playwright/create-call.spec.ts +++ b/playwright/create-call.spec.ts @@ -58,3 +58,41 @@ test("Start a new call then leave and show the feedback screen", async ({ page.getByRole("link", { name: "Not now, return to home screen" }), ).toBeVisible(); }); + +test("BugFix: When unmuting in lobby, you had to click twice to unmute in call", async ({ + page, +}) => { + await page.goto("/"); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("DoubleUnMute"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("me"); + await page.getByTestId("home_go").click(); + + const microphoneButton = page.getByTestId("incall_mute"); + const cameraButton = page.getByTestId("incall_videomute"); + + // Wait for devices to enumerate before the button enables. + await expect(microphoneButton).toBeEnabled({ timeout: 10_000 }); + + await microphoneButton.click(); + await cameraButton.click(); + + // Should be muted now + await expect(microphoneButton).toHaveAccessibleName("Unmute microphone"); + await expect(cameraButton).toHaveAccessibleName("Start video"); + + // Create the call and join + await page.getByTestId("lobby_joinCall").click(); + + // Give sometime for the all to be connected + // Check the number of participants + await expect(page.locator("div").filter({ hasText: /^1$/ })).toBeVisible(); + + // Click again on the mute button. it should unmute + await microphoneButton.click(); + await expect(microphoneButton).toHaveAccessibleName("Mute microphone"); + await cameraButton.click(); + await expect(cameraButton).toHaveAccessibleName("Stop video"); +}); diff --git a/playwright/reconnect.spec.ts b/playwright/reconnect.spec.ts index 1a8f2c28..bd4dd199 100644 --- a/playwright/reconnect.spec.ts +++ b/playwright/reconnect.spec.ts @@ -54,6 +54,8 @@ test("can only interact with header and footer while reconnecting", async ({ page.getByRole("switch", { name: "Mute microphone" }), ).toBeFocused(); await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Microphone" })).toBeFocused(); + await page.keyboard.press("Tab"); await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused(); // Most critically, we should be able to press the hangup button await page.getByRole("button", { name: "End call" }).click(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 544da4cb..6940982b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,8 @@ importers: specifier: ^7.16.5 version: 7.29.0 '@babel/preset-env': - specifier: ^7.22.20 - version: 7.29.2(@babel/core@7.29.0) + specifier: ^7.29.5 + version: 7.29.5(@babel/core@7.29.0) '@babel/preset-react': specifier: ^7.22.15 version: 7.28.5(@babel/core@7.29.0) @@ -47,21 +47,21 @@ importers: version: 11.7.12 '@livekit/components-core': specifier: ^0.12.0 - version: 0.12.13(livekit-client@2.18.9(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) + version: 0.12.13(livekit-client@2.18.10(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) '@livekit/components-react': specifier: ^2.0.0 - version: 2.9.21(livekit-client@2.18.9(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tslib@2.8.1) + version: 2.9.21(livekit-client@2.18.10(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tslib@2.8.1) '@livekit/protocol': specifier: ^1.42.2 version: 1.45.6 '@livekit/track-processors': specifier: ^0.7.1 - version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.18.9(@types/dom-mediacapture-record@1.0.22)) + version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.18.10(@types/dom-mediacapture-record@1.0.22)) '@mediapipe/tasks-vision': specifier: ^0.10.18 version: 0.10.34 '@playwright/test': - specifier: ^1.57.0 + specifier: ^1.59.0 version: 1.59.1 '@radix-ui/react-dialog': specifier: ^1.0.4 @@ -82,11 +82,14 @@ importers: specifier: ^3.0.0 version: 3.6.1 '@storybook/addon-docs': - specifier: ^10.3.3 - version: 10.3.5(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + specifier: ^10.3.6 + version: 10.3.6(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + '@storybook/addon-vitest': + specifier: ^10.3.6 + version: 10.3.6(@vitest/browser-playwright@4.1.5)(@vitest/browser@4.1.5)(@vitest/runner@4.1.5)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vitest@4.1.5) '@storybook/react-vite': - specifier: ^10.3.3 - version: 10.3.5(esbuild@0.28.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + specifier: ^10.3.6 + version: 10.3.6(esbuild@0.28.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) '@stylistic/eslint-plugin': specifier: ^3.0.0 version: 3.1.0(eslint@8.57.1)(typescript@5.9.3) @@ -146,15 +149,21 @@ importers: version: 10.3.1(react@19.2.5) '@vector-im/compound-design-tokens': specifier: ^10.0.0 - version: 10.1.0(@types/react@19.2.14)(react@19.2.5) + version: 10.1.1(@types/react@19.2.14)(react@19.2.5) '@vector-im/compound-web': specifier: ^9.3.0 - version: 9.3.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 9.3.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.1(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@vitejs/plugin-react': specifier: ^4.0.1 version: 4.7.0(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + '@vitest/browser-playwright': + specifier: ^4.1.5 + version: 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5) '@vitest/coverage-v8': specifier: ^4.0.18 + version: 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5) + '@vitest/ui': + specifier: 4.1.5 version: 4.1.5(vitest@4.1.5) babel-plugin-transform-vite-meta-env: specifier: ^1.0.3 @@ -199,8 +208,8 @@ importers: specifier: ^5.0.3 version: 5.0.3(eslint@8.57.1)(typescript@5.9.3) eslint-plugin-storybook: - specifier: ^10.3.3 - version: 10.3.5(eslint@8.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3) + specifier: ^10.3.6 + version: 10.3.6(eslint@8.57.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3) eslint-plugin-unicorn: specifier: ^56.0.0 version: 56.0.1(eslint@8.57.1) @@ -227,7 +236,7 @@ importers: version: 5.88.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.2)(typescript@5.9.3) livekit-client: specifier: ^2.18.1 - version: 2.18.9(@types/dom-mediacapture-record@1.0.22) + version: 2.18.10(@types/dom-mediacapture-record@1.0.22) lodash-es: specifier: ^4.17.21 version: 4.18.1 @@ -236,7 +245,7 @@ importers: version: 1.9.2 matrix-js-sdk: specifier: matrix-org/matrix-js-sdk#develop - version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/349e8c5023b74b7ee17b2e9a0cba6dfce6818d68 + version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9 matrix-widget-api: specifier: ^1.16.1 version: 1.17.0 @@ -259,8 +268,8 @@ importers: specifier: ^10.0.0 version: 10.6.1(postcss@8.5.11) posthog-js: - specifier: 1.160.3 - version: 1.160.3 + specifier: 1.374.0 + version: 1.374.0 prettier: specifier: ^3.0.0 version: 3.8.3 @@ -289,8 +298,8 @@ importers: specifier: ^1.42.1 version: 1.99.0 storybook: - specifier: ^10.3.3 - version: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^10.3.6 + version: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) typescript: specifier: ^5.8.3 version: 5.9.3 @@ -328,8 +337,8 @@ importers: specifier: ^3.6.0 version: 3.6.0(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) vitest: - specifier: ^4.0.18 - version: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + specifier: ^4.1.5 + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) vitest-axe: specifier: ^1.0.0-pre.3 version: 1.0.0-pre.5(vitest@4.1.5) @@ -361,8 +370,8 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} '@babel/core@7.29.0': @@ -502,6 +511,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3': + resolution: {integrity: sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} engines: {node: '>=6.9.0'} @@ -700,8 +715,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-systemjs@7.29.0': - resolution: {integrity: sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==} + '@babel/plugin-transform-modules-systemjs@7.29.4': + resolution: {integrity: sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -898,8 +913,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/preset-env@7.29.2': - resolution: {integrity: sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==} + '@babel/preset-env@7.29.5': + resolution: {integrity: sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -941,6 +956,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@bufbuild/protobuf@1.10.1': resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} @@ -1572,12 +1590,12 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - '@livekit/protocol@1.45.3': - resolution: {integrity: sha512-WmMxBTsy4dRBqcrswFwUUlgq3Z0nnhOqKR6tX749Rb/PcB1yBMUtrHxZvcsS6qi3/5+86zHeVG+exmu1sZqfJg==} - '@livekit/protocol@1.45.6': resolution: {integrity: sha512-YPDmrUiVe1EY/q/2bD+Fp+69DWq6LZgeH+G/KEbz07OIVf8hgAYzfb1FgiOdWLRpSj06+SuTmrOY604fWNuD3w==} + '@livekit/protocol@1.45.8': + resolution: {integrity: sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==} + '@livekit/track-processors@0.7.2': resolution: {integrity: sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==} peerDependencies: @@ -1666,6 +1684,78 @@ packages: '@octokit/types@13.10.0': resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + '@opentelemetry/api-logs@0.208.0': + resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.2.0': + resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.7.1': + resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.208.0': + resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.208.0': + resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.208.0': + resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.2.0': + resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.7.1': + resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.208.0': + resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.2.0': + resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.2.0': + resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} @@ -1874,6 +1964,45 @@ packages: engines: {node: '>=18'} hasBin: true + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@posthog/core@1.29.3': + resolution: {integrity: sha512-OvJSAzqVfZx+L7D874q56FVRTxOIsFBVB3wSB/Uny+DhmfNRGDi1rpZAruEmQYl9WQlQJb1q6JXGAC+rxVXjPA==} + + '@posthog/types@1.374.0': + resolution: {integrity: sha512-qouREpHIxsBS3Gc6a5gZvg6/ykK+4TJAs4wYTUIH/emH1HQfaaLrWzGoEm+/OPwlNxHzw4tQn9OOyxsmr9NF2g==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2679,23 +2808,41 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-docs@10.3.5': - resolution: {integrity: sha512-WuHbxia/o5TX4Rg/IFD0641K5qId/Nk0dxhmAUNoFs5L0+yfZUwh65XOBbzXqrkYmYmcVID4v7cgDRmzstQNkA==} + '@storybook/addon-docs@10.3.6': + resolution: {integrity: sha512-TvIdADVPtauxW0LzXIpIv7X6GxwetorhyNh+6+7MHC27XSBCWVxxRUwL63YeLlHTuXsIk0quG3b1xgwVRzWOJA==} peerDependencies: - storybook: ^10.3.5 + storybook: ^10.3.6 - '@storybook/builder-vite@10.3.5': - resolution: {integrity: sha512-i4KwCOKbhtlbQIbhm53+Kk7bMnxa0cwTn1pxmtA/x5wm1Qu7FrrBQV0V0DNjkUqzcSKo1CjspASJV/HlY0zYlw==} + '@storybook/addon-vitest@10.3.6': + resolution: {integrity: sha512-HXj7RrPJY+xzoNjL+xZu2oLw1fI5BA87Noh1NAXMPuECHR5R5fuRM/tTsJuIGXHFMO06FjSi/rekDIfCj1fL4w==} peerDependencies: - storybook: ^10.3.5 + '@vitest/browser': ^3.0.0 || ^4.0.0 + '@vitest/browser-playwright': ^4.0.0 + '@vitest/runner': ^3.0.0 || ^4.0.0 + storybook: ^10.3.6 + vitest: ^3.0.0 || ^4.0.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/runner': + optional: true + vitest: + optional: true + + '@storybook/builder-vite@10.3.6': + resolution: {integrity: sha512-gpvR/sE4BcrFtmQZ+Ker7zD23oQzoVeqD9nF6cK6yzY+Q0svJXyX2EPmFG4y+EwygD5/vNzDpP84gGMut8VRwg==} + peerDependencies: + storybook: ^10.3.6 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.3.5': - resolution: {integrity: sha512-qlEzNKxOjq86pvrbuMwiGD/bylnsXk1dg7ve0j77YFjEEchqtl7qTlrXvFdNaLA89GhW6D/EV6eOCu/eobPDgw==} + '@storybook/csf-plugin@10.3.6': + resolution: {integrity: sha512-9kBf7VRdRqTSIYo+rPtVn5yjYYyK8kP2QhEYx3oiXvfwy4RexmbJnhk/tXa/lNiTqukA1TqaWQ2+5MqF4fu6YQ==} peerDependencies: esbuild: ^0.28.0 rollup: '*' - storybook: ^10.3.5 + storybook: ^10.3.6 vite: '*' webpack: '*' peerDependenciesMeta: @@ -2717,27 +2864,27 @@ packages: 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 - '@storybook/react-dom-shim@10.3.5': - resolution: {integrity: sha512-Gw8R7XZm0zSUH0XAuxlQJhmizsLzyD6x00KOlP6l7oW9eQHXGfxg3seNDG3WrSAcW07iP1/P422kuiriQlOv7g==} + '@storybook/react-dom-shim@10.3.6': + resolution: {integrity: sha512-/Tu1gPu+Fw+zOnAGmxRmOD30FX3a04LxcTAKflEtdpmtIMVR5bA3qpjy+f5YhoyDCecbXyKmL1OeIU2FIIZHqQ==} peerDependencies: 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 - storybook: ^10.3.5 + storybook: ^10.3.6 - '@storybook/react-vite@10.3.5': - resolution: {integrity: sha512-UB5sJHeh26bfd8sNMx2YPGYRYmErIdTRaLOT28m4bykQIa1l9IgVktsYg/geW7KsJU0lXd3oTbnUjLD+enpi3w==} + '@storybook/react-vite@10.3.6': + resolution: {integrity: sha512-tySQRc+8q7V2NkylQMNJjDV8zXy6tkxb8oDqw/DIhHhI9Xn77MTKVZ8Cihbo5NMm7HYTB6xDKr6wqdSMgdufYQ==} peerDependencies: 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 - storybook: ^10.3.5 + storybook: ^10.3.6 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/react@10.3.5': - resolution: {integrity: sha512-tpLTLaVGoA6fLK3ReyGzZUricq7lyPaV2hLPpj5wqdXLV/LpRtAHClUpNoPDYSBjlnSjL81hMZijbkGC3mA+gw==} + '@storybook/react@10.3.6': + resolution: {integrity: sha512-oZQZ6xayWe5IdHmFUTL0TL8rX/gpNNh9gWhT2vzW5eeUvlkVG/RBKdsja6Ndrk2s1D9vcnwiI6r6CNXy3IEEmg==} peerDependencies: 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 - storybook: ^10.3.5 + storybook: ^10.3.6 typescript: '>= 4.9.x' peerDependenciesMeta: typescript: @@ -2953,6 +3100,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} @@ -2995,8 +3145,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.59.2': - resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + '@typescript-eslint/project-service@8.59.3': + resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -3013,8 +3163,8 @@ packages: resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.59.2': - resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + '@typescript-eslint/scope-manager@8.59.3': + resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/tsconfig-utils@8.58.2': @@ -3029,8 +3179,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.59.2': - resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + '@typescript-eslint/tsconfig-utils@8.59.3': + resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -3058,8 +3208,8 @@ packages: resolution: {integrity: sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.59.2': - resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + '@typescript-eslint/types@8.59.3': + resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@5.62.0': @@ -3083,8 +3233,8 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.59.2': - resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + '@typescript-eslint/typescript-estree@8.59.3': + resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -3109,8 +3259,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.59.2': - resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + '@typescript-eslint/utils@8.59.3': + resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -3128,8 +3278,8 @@ packages: resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.59.2': - resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + '@typescript-eslint/visitor-keys@8.59.3': + resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -3144,8 +3294,8 @@ packages: peerDependencies: react: '>= 16.8.0' - '@vector-im/compound-design-tokens@10.1.0': - resolution: {integrity: sha512-o+7DGx+NygpT2NPE1Jo//7NZDuyjzRH06eRchS0ZlkJicKx/impEmShmHU/XiE4P84BIFOo9eZ1Ws+rAym6Tuw==} + '@vector-im/compound-design-tokens@10.1.1': + resolution: {integrity: sha512-f2rdTilbPeOjrX7Mh9iTPcp5VergY7JLLWzKVjwMvpT0wtoFKwn59D1hwX2QInpiG70QTCxEdQFYLxQKvJQ74Q==} peerDependencies: '@types/react': '*' react: ^17 || ^18 || ^19.0.0 @@ -3173,6 +3323,17 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/browser-playwright@4.1.5': + resolution: {integrity: sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==} + peerDependencies: + playwright: '*' + vitest: 4.1.5 + + '@vitest/browser@4.1.5': + resolution: {integrity: sha512-iCDGI8c4yg+xmjUg2VsygdAUSIIB4x5Rht/P68OXy1hPELKXHDkzh87lkuTcdYmemRChDkEpB426MmDjzC0ziA==} + peerDependencies: + vitest: 4.1.5 + '@vitest/coverage-v8@4.1.5': resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} peerDependencies: @@ -3217,6 +3378,11 @@ packages: '@vitest/spy@4.1.5': resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + '@vitest/ui@4.1.5': + resolution: {integrity: sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==} + peerDependencies: + vitest: 4.1.5 + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -3693,6 +3859,9 @@ packages: core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3915,6 +4084,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.5: + resolution: {integrity: sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -4165,11 +4337,11 @@ packages: eslint: ^8.0.0 typescript: '>=4.0.0' - eslint-plugin-storybook@10.3.5: - resolution: {integrity: sha512-rEFkfU3ypF44GpB4tiJ9EFDItueoGvGi3+weLHZax2ON2MB7VIDsxdSUGvIU5tMURg+oWYlpzCyLm4TpDq2deA==} + eslint-plugin-storybook@10.3.6: + resolution: {integrity: sha512-8udrL+Rmp5LFaZvgRe4J226X1MYls25bWCyHuzR5X8s2qbFTryX+wKC+o/0Ato4A1AvwnDg8OOMPc6yWJ9JpcA==} peerDependencies: eslint: '>=8' - storybook: ^10.3.5 + storybook: ^10.3.6 eslint-plugin-unicorn@56.0.1: resolution: {integrity: sha512-FwVV0Uwf8XPfVnKSGpMg7NtlZh0G0gBarCaFcMUOoqPxXryxdYxTRRv4kH6B9TFCVIrjRXG+emcxIk2ayZilog==} @@ -4311,6 +4483,9 @@ packages: fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -5023,8 +5198,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - livekit-client@2.18.9: - resolution: {integrity: sha512-l0cADcxxBCWCBMtU9eWY6RpdbRfgA5c1/05yngQXo08mcy3VOttmSE2pNZ74k2B2zQym149g5/Y1B3vq2FWwlw==} + livekit-client@2.18.10: + resolution: {integrity: sha512-0cmb1P/M78udlP3MP0xGz6KqgOqd9z/eH9ka+RMbFwISABfhSMpQWDEK39Tia4xnbKjk468+rVynjGkxokY1uA==} peerDependencies: '@types/dom-mediacapture-record': ^1 @@ -5053,6 +5228,9 @@ packages: resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} engines: {node: '>= 0.6.0'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -5098,9 +5276,9 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/349e8c5023b74b7ee17b2e9a0cba6dfce6818d68: - resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/349e8c5023b74b7ee17b2e9a0cba6dfce6818d68} - version: 41.4.0 + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9: + resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9} + version: 41.5.0 engines: {node: '>=22.0.0'} matrix-widget-api@1.17.0: @@ -5154,6 +5332,10 @@ packages: resolution: {integrity: sha512-Q9wJ/xhzeD9Wua1MwDN2v3ah3HENsUVSlzzL9Qw149cL9hHZkXtQGl3Eq36BbdLV+/qUwaP1WtJQ+H/+Oxso8g==} engines: {node: 20 || 22 || 24} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -5429,6 +5611,10 @@ packages: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5597,8 +5783,8 @@ packages: resolution: {integrity: sha512-5dDj8+lmvA8XB78SmzGI8NlQoksv7IfutGWeVZxiixHbO+p4LDPT3wuG/D9sM/wrjZZ9I+Siy/e117vbFPxSZg==} engines: {node: ^10 || ^12 || >=14} - posthog-js@1.160.3: - resolution: {integrity: sha512-mGvxOIlWPtdPx8EI0MQ81wNKlnH2K0n4RqwQOl044b34BCKiFVzZ7Hc7geMuZNaRAvCi5/5zyGeWHcAYZQxiMQ==} + posthog-js@1.374.0: + resolution: {integrity: sha512-3M2xsHXU7Hl64KGZjljq13jIKiJ4N7npY1n+1Q7VQmQKdVsoTc9geaeoHprZEZCMXp3b2qbWZEvIYjekUN5lAg==} preact@10.29.1: resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==} @@ -5638,6 +5824,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protobufjs@7.5.9: + resolution: {integrity: sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==} + engines: {node: '>=12.0.0'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -5660,6 +5850,9 @@ packages: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + querystring-es3@0.2.1: resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==} engines: {node: '>=0.4.x'} @@ -6059,6 +6252,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -6116,14 +6313,17 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - storybook@10.3.5: - resolution: {integrity: sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw==} + storybook@10.3.6: + resolution: {integrity: sha512-vbSz7g/1rGMC1uAULqMZjALkIuLu2QABqfhRYhyr/11kzyesi+vAmwyJLukZP1FfecxGOgMwOh6GS0YsGpHAvQ==} hasBin: true peerDependencies: prettier: ^2 || ^3 + vite-plus: ^0.1.15 peerDependenciesMeta: prettier: optional: true + vite-plus: + optional: true stream-browserify@3.0.0: resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} @@ -6295,6 +6495,10 @@ packages: toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -6683,8 +6887,8 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} - web-vitals@4.2.4: - resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + web-vitals@5.2.0: + resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -6883,7 +7087,7 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.3': {} '@babel/core@7.29.0': dependencies: @@ -6933,7 +7137,7 @@ snapshots: '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.2 lru-cache: 5.1.1 @@ -7071,6 +7275,14 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -7286,7 +7498,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0)': + '@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) @@ -7501,9 +7713,9 @@ snapshots: '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) '@babel/helper-plugin-utils': 7.28.6 - '@babel/preset-env@7.29.2(@babel/core@7.29.0)': + '@babel/preset-env@7.29.5(@babel/core@7.29.0)': dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.3 '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 @@ -7511,6 +7723,7 @@ snapshots: '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.3(@babel/core@7.29.0) '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.0) '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) @@ -7542,7 +7755,7 @@ snapshots: '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-modules-systemjs': 7.29.4(@babel/core@7.29.0) '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) @@ -7634,6 +7847,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} + '@bufbuild/protobuf@1.10.1': {} '@codecov/bundler-plugin-core@1.9.1': @@ -8211,21 +8426,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@livekit/components-core@0.12.13(livekit-client@2.18.9(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': + '@livekit/components-core@0.12.13(livekit-client@2.18.10(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': dependencies: '@floating-ui/dom': 1.7.4 - livekit-client: 2.18.9(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.18.10(@types/dom-mediacapture-record@1.0.22) loglevel: 1.9.1 rxjs: 7.8.2 tslib: 2.8.1 - '@livekit/components-react@2.9.21(livekit-client@2.18.9(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tslib@2.8.1)': + '@livekit/components-react@2.9.21(livekit-client@2.18.10(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tslib@2.8.1)': dependencies: - '@livekit/components-core': 0.12.13(livekit-client@2.18.9(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) + '@livekit/components-core': 0.12.13(livekit-client@2.18.10(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) clsx: 2.1.1 events: 3.3.0 jose: 6.2.3 - livekit-client: 2.18.9(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.18.10(@types/dom-mediacapture-record@1.0.22) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) tslib: 2.8.1 @@ -8233,19 +8448,19 @@ snapshots: '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.45.3': - dependencies: - '@bufbuild/protobuf': 1.10.1 - '@livekit/protocol@1.45.6': dependencies: '@bufbuild/protobuf': 1.10.1 - '@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.18.9(@types/dom-mediacapture-record@1.0.22))': + '@livekit/protocol@1.45.8': + dependencies: + '@bufbuild/protobuf': 1.10.1 + + '@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.18.10(@types/dom-mediacapture-record@1.0.22))': dependencies: '@mediapipe/tasks-vision': 0.10.34 '@types/dom-mediacapture-transform': 0.1.11 - livekit-client: 2.18.9(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.18.10(@types/dom-mediacapture-record@1.0.22) '@matrix-org/matrix-sdk-crypto-wasm@18.2.0': {} @@ -8338,6 +8553,82 @@ snapshots: dependencies: '@octokit/openapi-types': 24.2.0 + '@opentelemetry/api-logs@0.208.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.1) + protobufjs: 7.5.9 + + '@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@oxc-project/types@0.127.0': {} '@oxc-resolver/binding-android-arm-eabi@11.19.1': @@ -8473,6 +8764,36 @@ snapshots: dependencies: playwright: 1.59.1 + '@polka/url@1.0.0-next.29': {} + + '@posthog/core@1.29.3': + dependencies: + '@posthog/types': 1.374.0 + + '@posthog/types@1.374.0': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -9139,15 +9460,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.3.5(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))': + '@storybook/addon-docs@10.3.6(@types/react@19.2.14)(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.5) - '@storybook/csf-plugin': 10.3.5(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + '@storybook/csf-plugin': 10.3.6(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + '@storybook/react-dom-shim': 10.3.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -9156,10 +9477,24 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.3.5(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))': + '@storybook/addon-vitest@10.3.6(@vitest/browser-playwright@4.1.5)(@vitest/browser@4.1.5)(@vitest/runner@4.1.5)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vitest@4.1.5)': dependencies: - '@storybook/csf-plugin': 10.3.5(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) - storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@storybook/global': 5.0.0 + '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + optionalDependencies: + '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5) + '@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5) + '@vitest/runner': 4.1.5 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + transitivePeerDependencies: + - react + - react-dom + + '@storybook/builder-vite@10.3.6(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))': + dependencies: + '@storybook/csf-plugin': 10.3.6(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ts-dedent: 2.2.0 vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3) transitivePeerDependencies: @@ -9167,9 +9502,9 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.3.5(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))': + '@storybook/csf-plugin@10.3.6(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: - storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.0 @@ -9183,25 +9518,25 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@storybook/react-dom-shim@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': + '@storybook/react-dom-shim@10.3.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))': dependencies: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@storybook/react-vite@10.3.5(esbuild@0.28.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))': + '@storybook/react-vite@10.3.6(esbuild@0.28.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) '@rollup/pluginutils': 5.3.0(rollup@4.60.1) - '@storybook/builder-vite': 10.3.5(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) - '@storybook/react': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3) + '@storybook/builder-vite': 10.3.6(esbuild@0.28.0)(rollup@4.60.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + '@storybook/react': 10.3.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 react: 19.2.5 react-docgen: 8.0.3 react-dom: 19.2.5(react@19.2.5) resolve: 1.22.12 - storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tsconfig-paths: 4.2.0 vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3) transitivePeerDependencies: @@ -9211,15 +9546,15 @@ snapshots: - typescript - webpack - '@storybook/react@10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)': + '@storybook/react@10.3.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.3.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + '@storybook/react-dom-shim': 10.3.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) react: 19.2.5 react-docgen: 8.0.3 react-docgen-typescript: 2.4.0(typescript@5.9.3) react-dom: 19.2.5(react@19.2.5) - storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -9446,6 +9781,9 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/uuid@10.0.0': {} '@types/yargs-parser@21.0.3': {} @@ -9508,10 +9846,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + '@typescript-eslint/project-service@8.59.3(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) - '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) + '@typescript-eslint/types': 8.59.3 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -9532,10 +9870,10 @@ snapshots: '@typescript-eslint/types': 8.59.0 '@typescript-eslint/visitor-keys': 8.59.0 - '@typescript-eslint/scope-manager@8.59.2': + '@typescript-eslint/scope-manager@8.59.3': dependencies: - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/visitor-keys': 8.59.2 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': dependencies: @@ -9545,7 +9883,7 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.59.3(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -9569,7 +9907,7 @@ snapshots: '@typescript-eslint/types@8.59.1': {} - '@typescript-eslint/types@8.59.2': {} + '@typescript-eslint/types@8.59.3': {} '@typescript-eslint/typescript-estree@5.62.0(typescript@5.9.3)': dependencies: @@ -9615,12 +9953,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.3(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/visitor-keys': 8.59.2 + '@typescript-eslint/project-service': 8.59.3(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.0 @@ -9667,12 +10005,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.2(eslint@8.57.1)(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.3(eslint@8.57.1)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) - '@typescript-eslint/scope-manager': 8.59.2 - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -9693,9 +10031,9 @@ snapshots: '@typescript-eslint/types': 8.59.0 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.59.2': + '@typescript-eslint/visitor-keys@8.59.3': dependencies: - '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/types': 8.59.3 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} @@ -9707,12 +10045,12 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.5 - '@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5)': + '@vector-im/compound-design-tokens@10.1.1(@types/react@19.2.14)(react@19.2.5)': optionalDependencies: '@types/react': 19.2.14 react: 19.2.5 - '@vector-im/compound-web@9.3.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@vector-im/compound-web@9.3.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(@vector-im/compound-design-tokens@10.1.1(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@fontsource/inconsolata': 5.2.8 @@ -9723,7 +10061,7 @@ snapshots: '@radix-ui/react-progress': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-separator': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.5) - '@vector-im/compound-design-tokens': 10.1.0(@types/react@19.2.14)(react@19.2.5) + '@vector-im/compound-design-tokens': 10.1.1(@types/react@19.2.14)(react@19.2.5) classnames: 2.5.1 react: 19.2.5 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -9745,7 +10083,37 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': + '@vitest/browser-playwright@4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5)': + dependencies: + '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5) + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + playwright: 1.59.1 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.5 @@ -9757,7 +10125,9 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + optionalDependencies: + '@vitest/browser': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5) '@vitest/expect@3.2.4': dependencies: @@ -9810,6 +10180,17 @@ snapshots: '@vitest/spy@4.1.5': {} + '@vitest/ui@4.1.5(vitest@4.1.5)': + dependencies: + '@vitest/utils': 4.1.5 + fflate: 0.8.2 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -10000,7 +10381,7 @@ snapshots: babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): dependencies: - '@babel/compat-data': 7.29.0 + '@babel/compat-data': 7.29.3 '@babel/core': 7.29.0 '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.0) semver: 6.3.1 @@ -10343,6 +10724,8 @@ snapshots: dependencies: browserslist: 4.28.2 + core-js@3.49.0: {} + core-util-is@1.0.3: {} cosmiconfig@8.3.6(typescript@5.9.3): @@ -10575,6 +10958,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.5: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 @@ -10856,7 +11243,7 @@ snapshots: eslint-plugin-jest@29.15.2(@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.59.2(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 optionalDependencies: '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) @@ -10966,11 +11353,11 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-storybook@10.3.5(eslint@8.57.1)(storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3): + eslint-plugin-storybook@10.3.6(eslint@8.57.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.58.2(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 - storybook: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + storybook: 10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) transitivePeerDependencies: - supports-color - typescript @@ -11151,6 +11538,8 @@ snapshots: fflate@0.4.8: {} + fflate@0.8.2: {} + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -11914,10 +12303,10 @@ snapshots: lines-and-columns@1.2.4: {} - livekit-client@2.18.9(@types/dom-mediacapture-record@1.0.22): + livekit-client@2.18.10(@types/dom-mediacapture-record@1.0.22): dependencies: '@livekit/mutex': 1.1.1 - '@livekit/protocol': 1.45.3 + '@livekit/protocol': 1.45.8 '@types/dom-mediacapture-record': 1.0.22 events: 3.3.0 jose: 6.2.3 @@ -11945,6 +12334,8 @@ snapshots: loglevel@1.9.2: {} + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -11990,7 +12381,7 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/349e8c5023b74b7ee17b2e9a0cba6dfce6818d68: + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9: dependencies: '@babel/runtime': 7.29.2 '@matrix-org/matrix-sdk-crypto-wasm': 18.2.0 @@ -12051,6 +12442,8 @@ snapshots: mktemp@2.0.2: {} + mrmime@2.0.1: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -12381,6 +12774,8 @@ snapshots: pngjs@5.0.0: {} + pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-attribute-case-insensitive@7.0.1(postcss@8.5.11): @@ -12616,11 +13011,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - posthog-js@1.160.3: + posthog-js@1.374.0: dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.208.0 + '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1) + '@posthog/core': 1.29.3 + '@posthog/types': 1.374.0 + core-js: 3.49.0 + dompurify: 3.4.5 fflate: 0.4.8 preact: 10.29.1 - web-vitals: 4.2.4 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.2.0 preact@10.29.1: {} @@ -12653,6 +13058,21 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protobufjs@7.5.9: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.2 + long: 5.3.2 + proxy-from-env@1.1.0: {} public-encrypt@4.0.3: @@ -12678,6 +13098,8 @@ snapshots: dependencies: side-channel: 1.1.0 + query-selector-shadow-dom@1.0.1: {} + querystring-es3@0.2.1: {} queue-microtask@1.2.3: {} @@ -13141,6 +13563,12 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} slash@3.0.0: {} @@ -13195,7 +13623,7 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - storybook@10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + storybook@10.3.6(@testing-library/dom@10.4.1)(prettier@3.8.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -13429,6 +13857,8 @@ snapshots: toggle-selection@1.0.6: {} + totalist@3.0.1: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -13788,9 +14218,9 @@ snapshots: axe-core: 4.11.3 chalk: 5.6.2 lodash-es: 4.18.1 - vitest: 4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) - vitest@4.1.5(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.5)(@vitest/coverage-v8@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3)) @@ -13813,8 +14243,11 @@ snapshots: vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 24.12.2 - '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + '@vitest/browser-playwright': 4.1.5(playwright@1.59.1)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(yaml@2.8.3))(vitest@4.1.5) + '@vitest/coverage-v8': 4.1.5(@vitest/browser@4.1.5)(vitest@4.1.5) + '@vitest/ui': 4.1.5(vitest@4.1.5) jsdom: 26.1.0 transitivePeerDependencies: - msw @@ -13836,7 +14269,7 @@ snapshots: walk-up-path@4.0.0: {} - web-vitals@4.2.4: {} + web-vitals@5.2.0: {} webidl-conversions@3.0.1: {} diff --git a/renovate.json b/renovate.json index 39fbf0c1..18793689 100644 --- a/renovate.json +++ b/renovate.json @@ -49,6 +49,11 @@ "matchDepNames": ["vaul"], "prHeader": "Please review modals on mobile for visual regressions." }, + { + "groupName": "PostHog", + "matchDepNames": ["posthog-js"], + "prHeader": "Please ensure that all analytics data is still appropriately sanitized." + }, { "groupName": "embedded package dependencies", "matchFileNames": ["embedded/**/*"] @@ -59,7 +64,7 @@ } ], "semanticCommits": "disabled", - "ignoreDeps": ["posthog-js", "eslint-plugin-matrix-org"], + "ignoreDeps": ["eslint-plugin-matrix-org"], "vulnerabilityAlerts": { "schedule": ["at any time"], "prHourlyLimit": 0, diff --git a/src/AppBar.module.css b/src/AppBar.module.css index 5f7888d2..13e3b759 100644 --- a/src/AppBar.module.css +++ b/src/AppBar.module.css @@ -1,5 +1,21 @@ .bar { flex-shrink: 0; + position: relative; +} + +/* Pseudo-element for the gradient background */ +.bar::before { + content: ""; + position: absolute; + inset-inline: 0; + /* Extend the gradient beyond the bottom of the header for readability */ + inset-block: -24px; + z-index: var(--call-view-header-footer-layer); + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0) 0%, + var(--cpd-color-bg-canvas-default) 100% + ); } .bar > header { diff --git a/src/analytics/PosthogAnalytics.test.ts b/src/analytics/PosthogAnalytics.test.ts index 49af5eae..7c1128ad 100644 --- a/src/analytics/PosthogAnalytics.test.ts +++ b/src/analytics/PosthogAnalytics.test.ts @@ -14,8 +14,13 @@ import { beforeAll, afterAll, } from "vitest"; +import posthog, { type CaptureResult } from "posthog-js"; -import { PosthogAnalytics } from "./PosthogAnalytics"; +import { + Anonymity, + santizeSensitiveData, + PosthogAnalytics, +} from "./PosthogAnalytics"; import { mockConfig } from "../utils/test"; describe("PosthogAnalytics", () => { @@ -88,4 +93,154 @@ describe("PosthogAnalytics", () => { expect(PosthogAnalytics.instance.isEnabled()).toBe(true); }); }); + + describe("applyPrivacyFilters", () => { + const makeEvent = (properties: Record): CaptureResult => + ({ event: "anyEvent", properties }) as unknown as CaptureResult; + + it("drops $initial_person_info regardless of anonymity", () => { + const out = santizeSensitiveData( + makeEvent({ + $current_url: "https://call.example.com/some/private/path", + $initial_person_info: { + r: "https://example.com/referrer", + u: "https://call.example.com/some/private/path", + }, + }), + Anonymity.Pseudonymous, + ); + expect(out?.properties).not.toHaveProperty("$initial_person_info"); + }); + + it("strips hash from $current_url", () => { + const out = santizeSensitiveData( + makeEvent({ $current_url: "https://call.example.com/#/x/y/z" }), + Anonymity.Pseudonymous, + ); + expect(out?.properties["$current_url"]).not.toContain("/x/y/z"); + }); + + it("nulls referrer and device fields when anonymous", () => { + const out = santizeSensitiveData( + makeEvent({ + $current_url: "https://x/y", + $referrer: "https://leaky", + $initial_referrer: "https://leaky-too", + $device_id: "uuid", + }), + Anonymity.Anonymous, + ); + expect(out?.properties["$referrer"]).toBeUndefined(); + expect(out?.properties["$initial_referrer"]).toBeUndefined(); + expect(out?.properties["$device_id"]).toBeUndefined(); + }); + + it("passes null events through unchanged", () => { + expect(santizeSensitiveData(null, Anonymity.Pseudonymous)).toBeNull(); + }); + + it("strips URL fields nested inside $set_once", () => { + const secretUrl = + "https://call.example.com/room/#/?password=hunter2&roomId=abc"; + const out = santizeSensitiveData( + makeEvent({ + $current_url: "https://call.example.com/x", + $set_once: { + $current_url: secretUrl, + $initial_current_url: secretUrl, + $session_entry_url: secretUrl, + $initial_person_info: { r: "x", u: secretUrl }, + }, + }), + Anonymity.Pseudonymous, + ); + + const setOnce = out?.properties["$set_once"] as Record; + expect(setOnce["$current_url"]).not.toContain("password"); + expect(setOnce["$initial_current_url"]).not.toContain("password"); + expect(setOnce).not.toHaveProperty("$session_entry_url"); + expect(setOnce).not.toHaveProperty("$initial_person_info"); + }); + + it("strips URL fields nested inside $set", () => { + const secretUrl = + "https://call.example.com/room/#/?password=hunter2&roomId=abc"; + const out = santizeSensitiveData( + makeEvent({ + $current_url: "https://call.example.com/x", + $set: { + $current_url: secretUrl, + $session_entry_url: secretUrl, + }, + }), + Anonymity.Pseudonymous, + ); + + const set = out?.properties["$set"] as Record; + expect(set["$current_url"]).not.toContain("password"); + expect(set).not.toHaveProperty("$session_entry_url"); + }); + + it("nulls referrer fields inside $set_once when anonymous", () => { + const out = santizeSensitiveData( + makeEvent({ + $current_url: "https://x/y", + $set_once: { + $initial_referrer: "https://leaky", + $initial_referring_domain: "leaky", + }, + }), + Anonymity.Anonymous, + ); + + const setOnce = out?.properties["$set_once"] as Record; + expect(setOnce["$initial_referrer"]).toBeUndefined(); + expect(setOnce["$initial_referring_domain"]).toBeUndefined(); + }); + }); + + // Verifies that applyPrivacyFilters is actually wired into posthog.init via + // the before_send hook β€” guards against typos in the option name or future + // posthog-js bumps renaming/removing the hook. The filter logic itself is + // covered by the applyPrivacyFilters block above. + describe("posthog.init wiring", () => { + beforeAll(() => { + vi.stubEnv("VITE_PACKAGE", "full"); + }); + + beforeEach(() => { + mockConfig({ + posthog: { + api_host: "https://api.example.com.localhost", + api_key: "api_key", + }, + }); + PosthogAnalytics.resetInstance(); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + it("passes events through the privacy filter via before_send", () => { + const initSpy = vi.spyOn(posthog, "init"); + expect(PosthogAnalytics.instance.isEnabled()).toBe(true); + + const beforeSend = initSpy.mock.calls[0][1]?.before_send; + expect(beforeSend).toBeInstanceOf(Function); + + const event = { + event: "anyEvent", + properties: { + $current_url: "https://call.example.com/x/y", + $initial_person_info: { r: "x" }, + }, + } as unknown as CaptureResult; + + const out = (beforeSend as (e: CaptureResult) => CaptureResult | null)( + event, + ); + expect(out?.properties).not.toHaveProperty("$initial_person_info"); + }); + }); }); diff --git a/src/analytics/PosthogAnalytics.ts b/src/analytics/PosthogAnalytics.ts index 46223afe..01a146e0 100644 --- a/src/analytics/PosthogAnalytics.ts +++ b/src/analytics/PosthogAnalytics.ts @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. import posthog, { type CaptureOptions, + type CaptureResult, type PostHog, type Properties, } from "posthog-js"; @@ -26,6 +27,7 @@ import { QualitySurveyEventTracker, CallDisconnectedEventTracker, CallConnectDurationTracker, + CallReconnectingTracker, } from "./PosthogEvents"; import { Config } from "../config/Config"; import { getUrlParams } from "../UrlParams"; @@ -64,6 +66,73 @@ export enum RegistrationType { Registered, } +// Sanitize URL / referrer / device fields on a single posthog properties bag. +// Applied to event.properties and to the person-profile bags ($set / $set_once), +// since posthog mirrors the same URL fields into those. +function stripSensitiveFields( + obj: Properties | undefined, + anonymity: Anonymity, +): void { + if (!obj) return; + + if (anonymity === Anonymity.Anonymous) { + // drop referrer information for anonymous users + delete obj["$referrer"]; + delete obj["$referring_domain"]; + delete obj["$initial_referrer"]; + delete obj["$initial_referring_domain"]; + + // drop device ID, which is a UUID persisted in local storage + delete obj["$device_id"]; + } + + // the url leaks a lot of private data like the call name or the user + // (room password / room ID can land in the hash/query). Strip down to + // scheme + host so we still get host-level insights (develop / main / sfu). + for (const key of ["$current_url", "$initial_current_url"]) { + if (typeof obj[key] === "string") { + try { + const url = new URL(obj[key]); + obj[key] = url.protocol + "//" + url.hostname + url.pathname; + } catch { + obj[key] = null; + } + } + } + + // $session_entry_url carries the full untrimmed URL; $initial_person_info + // bundles initial referrer + URL into a nested object that bypasses the + // per-key strips above. Drop both. + delete obj["$session_entry_url"]; + delete obj["$initial_person_info"]; +} + +/** + * Strip PII from posthog's built-in properties (URL, referrer fields, + * device ID, $initial_person_info, $session_entry_url) before events leave + * the client. Also applied to the person-profile bags ($set / $set_once), + * which mirror the same URL fields. + * See src/utils/event-utils.ts in posthog-js (getEventProperties, getPersonInfo) + * for the list of properties posthog sets automatically. + */ +export function santizeSensitiveData( + event: CaptureResult | null, + anonymity: Anonymity, +): CaptureResult | null { + if (event === null) return null; + + stripSensitiveFields(event.properties, anonymity); + // posthog can stash person-profile updates either at the top level + // of CaptureResult or nested inside properties depending on the pipeline + // stage; clean both spots so nothing slips through. + stripSensitiveFields(event.$set, anonymity); + stripSensitiveFields(event.$set_once, anonymity); + stripSensitiveFields(event.properties["$set"], anonymity); + stripSensitiveFields(event.properties["$set_once"], anonymity); + + return event; +} + interface PlatformProperties { appVersion: string; matrixBackend: "embedded" | "jssdk"; @@ -128,13 +197,16 @@ export class PosthogAnalytics { } if (apiKey && apiHost) { + const beforeSend = (event: CaptureResult | null): CaptureResult | null => + santizeSensitiveData(event, this.anonymity); this.posthog.init(apiKey, { api_host: apiHost, autocapture: false, mask_all_text: true, mask_all_element_attributes: true, + mask_personal_data_properties: true, capture_pageview: false, - sanitize_properties: this.sanitizeProperties, + before_send: beforeSend, respect_dnt: true, advanced_disable_decide: true, }); @@ -147,34 +219,6 @@ export class PosthogAnalytics { } } - private sanitizeProperties = ( - properties: Properties, - _eventName: string, - ): Properties => { - // Callback from posthog to sanitize properties before sending them to the server. - // Here we sanitize posthog's built in properties which leak PII e.g. url reporting. - // See utils.js _.info.properties in posthog-js. - - if (this.anonymity == Anonymity.Anonymous) { - // drop referrer information for anonymous users - properties["$referrer"] = null; - properties["$referring_domain"] = null; - properties["$initial_referrer"] = null; - properties["$initial_referring_domain"] = null; - - // drop device ID, which is a UUID persisted in local storage - properties["$device_id"] = null; - } - // the url leaks a lot of private data like the call name or the user. - // Its stripped down to the bare minimum to only give insights about the host (develop, main or sfu) - properties["$current_url"] = (properties["$current_url"] as string) - .split("/") - .slice(0, 3) - .join(""); - - return properties; - }; - private registerSuperProperties(properties: Properties): void { if (this.enabled) { this.posthog.register(properties); @@ -421,4 +465,5 @@ export class PosthogAnalytics { public eventQualitySurvey = new QualitySurveyEventTracker(); public eventCallDisconnected = new CallDisconnectedEventTracker(); public eventCallConnectDuration = new CallConnectDurationTracker(); + public eventCallReconnecting = new CallReconnectingTracker(); } diff --git a/src/analytics/PosthogEvents.test.ts b/src/analytics/PosthogEvents.test.ts index 35b86f5d..83ef4d7c 100644 --- a/src/analytics/PosthogEvents.test.ts +++ b/src/analytics/PosthogEvents.test.ts @@ -18,7 +18,11 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { PosthogAnalytics } from "./PosthogAnalytics"; -import { CallEndedTracker } from "./PosthogEvents"; +import { + CallEndedTracker, + CallReconnectingTracker, + type CallReconnectingReason, +} from "./PosthogEvents"; import { mockConfig } from "../utils/test"; const defaultCounters = { @@ -89,6 +93,11 @@ describe("CallEnded", () => { roomEventEncryptionKeysSent: 10, roomEventEncryptionKeysReceived: 5, roomEventEncryptionKeysReceivedAverageAge: 100, + callReconnectingCount: 0, + callReconnectingCountSync: 0, + callReconnectingCountMembership: 0, + callReconnectingCountProbablyLeft: 0, + callReconnectingCountLivekit: 0, }, { send_instantly: true }, ); @@ -159,4 +168,70 @@ describe("CallEnded", () => { { send_instantly: false }, ); }); + + it("includes per-reason reconnecting counts in CallEnded", () => { + const tracker = new CallEndedTracker(); + const mockSession = createMockRtcSession(); + + tracker.cacheStartCall(new Date()); + tracker.cacheReconnecting("sync"); + tracker.cacheReconnecting("sync"); + tracker.cacheReconnecting("livekit"); + tracker.cacheReconnecting("membership"); + tracker.track("test-call-id", 1, false, mockSession); + + expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + callReconnectingCount: 4, + callReconnectingCountSync: 2, + callReconnectingCountMembership: 1, + callReconnectingCountProbablyLeft: 0, + callReconnectingCountLivekit: 1, + }), + expect.anything(), + ); + }); +}); + +describe("CallReconnecting", () => { + beforeAll(() => { + mockConfig(); + }); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation( + () => {}, + ); + }); + + afterAll(() => { + PosthogAnalytics.resetInstance(); + }); + + it("tracks event with correct shape", () => { + const tracker = new CallReconnectingTracker(); + tracker.track("!room:example.org", "sync", 3.5); + + expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({ + eventName: "CallReconnecting", + callId: "!room:example.org", + reason: "sync", + reconnectDuration: 3.5, + }); + }); + + it.each([ + "sync", + "membership", + "probablyLeft", + "livekit", + ] as CallReconnectingReason[])("tracks reason %s correctly", (reason) => { + const tracker = new CallReconnectingTracker(); + tracker.track("!room:example.org", reason, 1.0); + + expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ reason, reconnectDuration: 1.0 }), + ); + }); }); diff --git a/src/analytics/PosthogEvents.ts b/src/analytics/PosthogEvents.ts index 5553829a..56ca08af 100644 --- a/src/analytics/PosthogEvents.ts +++ b/src/analytics/PosthogEvents.ts @@ -17,6 +17,7 @@ import { interface CallEnded extends IPosthogEvent { eventName: "CallEnded"; + // the callId posthog key is essentially a Matrix roomId callId: string; callParticipantsOnLeave: number; callParticipantsMax: number; @@ -24,16 +25,43 @@ interface CallEnded extends IPosthogEvent { roomEventEncryptionKeysSent: number; roomEventEncryptionKeysReceived: number; roomEventEncryptionKeysReceivedAverageAge: number; + callReconnectingCount: number; + callReconnectingCountSync: number; + callReconnectingCountMembership: number; + callReconnectingCountProbablyLeft: number; + callReconnectingCountLivekit: number; } export class CallEndedTracker { - private cache: { startTime?: Date; maxParticipantsCount: number } = { + private cache: { + startTime?: Date; + maxParticipantsCount: number; + reconnectingCount: number; + reconnectingCountByReason: Record; + } = { startTime: undefined, maxParticipantsCount: 0, + reconnectingCount: 0, + reconnectingCountByReason: { + sync: 0, + membership: 0, + probablyLeft: 0, + livekit: 0, + }, }; public cacheStartCall(time: Date): void { - this.cache.startTime = time; + this.cache = { + startTime: time, + maxParticipantsCount: 0, + reconnectingCount: 0, + reconnectingCountByReason: { + sync: 0, + membership: 0, + probablyLeft: 0, + livekit: 0, + }, + }; } public cacheParticipantCountChanged(count: number): void { @@ -43,6 +71,11 @@ export class CallEndedTracker { ); } + public cacheReconnecting(reason: CallReconnectingReason): void { + this.cache.reconnectingCount++; + this.cache.reconnectingCountByReason[reason]++; + } + public track( callId: string, callParticipantsNow: number, @@ -67,6 +100,14 @@ export class CallEndedTracker { .roomEventEncryptionKeysReceivedTotalAge / rtcSession.statistics.counters.roomEventEncryptionKeysReceived : 0, + callReconnectingCount: this.cache.reconnectingCount, + callReconnectingCountSync: this.cache.reconnectingCountByReason.sync, + callReconnectingCountMembership: + this.cache.reconnectingCountByReason.membership, + callReconnectingCountProbablyLeft: + this.cache.reconnectingCountByReason.probablyLeft, + callReconnectingCountLivekit: + this.cache.reconnectingCountByReason.livekit, }, { send_instantly: sendInstantly }, ); @@ -80,6 +121,7 @@ export class CallEndedTracker { interface CallStarted extends IPosthogEvent { eventName: "CallStarted"; + // the callId posthog key is essentially a Matrix roomId callId: string; } @@ -140,6 +182,7 @@ export class LoginTracker { interface MuteMicrophone { eventName: "MuteMicrophone"; targetMuteState: "mute" | "unmute"; + // the callId posthog key is essentially a Matrix roomId callId: string; } @@ -156,6 +199,7 @@ export class MuteMicrophoneTracker { interface MuteCamera { eventName: "MuteCamera"; targetMuteState: "mute" | "unmute"; + // the callId posthog key is essentially a Matrix roomId callId: string; } @@ -171,6 +215,7 @@ export class MuteCameraTracker { interface UndecryptableToDeviceEvent { eventName: "UndecryptableToDeviceEvent"; + // the callId posthog key is essentially a Matrix roomId callId: string; } @@ -185,6 +230,7 @@ export class UndecryptableToDeviceEventTracker { interface QualitySurveyEvent { eventName: "QualitySurvey"; + // the callId posthog key is essentially a Matrix roomId callId: string; feedbackText: string; stars: number; @@ -249,3 +295,32 @@ export class CallConnectDurationTracker { ); } } + +export type CallReconnectingReason = + | "sync" + | "membership" + | "probablyLeft" + | "livekit"; + +interface CallReconnecting extends IPosthogEvent { + eventName: "CallReconnecting"; + // the callId posthog key is essentially a Matrix roomId + callId: string; + reason: CallReconnectingReason; + reconnectDuration: number; +} + +export class CallReconnectingTracker { + public track( + callId: string, + reason: CallReconnectingReason, + reconnectDuration: number, + ): void { + PosthogAnalytics.instance.trackEvent({ + eventName: "CallReconnecting", + callId, + reason, + reconnectDuration, + }); + } +} diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 7090a338..e9a7537c 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -5,18 +5,41 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { fn } from "storybook/test"; +import { expect, fn, userEvent, within } from "storybook/test"; import { BehaviorSubject } from "rxjs"; -import { type ReactNode } from "react"; +import { type JSX, type ReactNode } from "react"; import { Link } from "@vector-im/compound-web"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { CallFooter, type FooterProps } from "./CallFooter"; +import { CallFooter, type FooterSnapshot } from "./CallFooter"; import inCallViewStyles from "../room/InCallView.module.css"; +import { useStaticViewModel } from "../state/ViewModel"; import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { type ReactionOption } from "../reactions"; +import { type GridMode } from "../state/CallViewModel/CallViewModel"; +// consts for tests +const reactionIdentifier = "@user:example.com:DEVICE"; +const reactionData = { + handsRaised$: new BehaviorSubject({}), + reactions$: new BehaviorSubject({}), +}; -function CallFooterWrapper(props: FooterProps): ReactNode { +/** + * A wrapper component that is used for: + * - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm) + * - Add additional react context + * The paraeters are all params from the FooterSnapshot, + * the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter. + * `children` is used for the "Back to Recents" button in the lobby stories, but can be used for anything really. + * @returns A component that renders the CallFooter based on primitive snapshot params (not a view model). Which is what we want for storybook. + */ +function CallFooterStoryWrapper({ + children, + ...vmSnapshot +}: FooterSnapshot & { + children?: false | JSX.Element | JSX.Element[] | undefined; +}): ReactNode { + const vm = useStaticViewModel(vmSnapshot); return (

Promise.resolve(), }} > - +
); } const meta = { - component: CallFooterWrapper, -} satisfies Meta; + component: CallFooterStoryWrapper, +} satisfies Meta; export default meta; type Story = StoryObj; -const reactionIdentifier = "@user:example.com:DEVICE"; -const reactionData = { - handsRaised$: new BehaviorSubject({}), - reactions$: new BehaviorSubject({}), -}; - const fnArgType = { control: { type: "select" as const }, options: ["MockedCallback", "undefined"], mapping: { MockedCallback: fn(), undefined: undefined }, }; + export const Default: Story = { args: { - hideLogo: true, + showLogo: false, layoutMode: "grid", audioEnabled: true, videoEnabled: true, @@ -61,13 +79,34 @@ export const Default: Story = { toggleAudio: fn(), toggleVideo: fn(), toggleScreenSharing: fn(), + toggleBlur: fn(), + videoBlurEnabled: true, hangup: fn(), + buttonSize: "lg", + showFooter: true, + hideControls: false, + asOverlay: false, + sharingScreen: false, + audioOutputSwitcher: undefined, + reactionIdentifier: undefined, + reactionData: undefined, + debugTileLayout: false, + tileStoreGeneration: undefined, + audioOptions: [], + videoOptions: [], + selectedAudio: undefined, + selectedVideo: undefined, + selectAudioButtonOption: undefined, + selectVideoButtonOption: undefined, }, parameters: { layout: "fullscreen", }, argTypes: { - layoutMode: { control: "radio", options: ["grid", "spotlight"] }, + layoutMode: { + control: "radio", + options: ["grid", "spotlight"] satisfies GridMode[], + }, audioOutputSwitcher: { control: "select", options: ["NoOutputCallback", "speaker", "earpiece"], @@ -95,12 +134,12 @@ export const WithAudioAndVideoOptions: Story = { audioEnabled: false, videoEnabled: true, audioOptions: [ - { label: "Microphone 1", id: "1" }, - { label: "Microphone 2", id: "2" }, + { label: { type: "name", name: "Microphone 1" }, id: "1" }, + { label: { type: "name", name: "Microphone 2" }, id: "2" }, ], videoOptions: [ - { label: "Camera 1", id: "1" }, - { label: "Camera 2", id: "2" }, + { label: { type: "name", name: "Camera 1" }, id: "1" }, + { label: { type: "name", name: "Camera 2" }, id: "2" }, ], selectedAudio: "2", selectedVideo: "1", @@ -110,7 +149,7 @@ export const WithLogo: Story = { ...Default, args: { ...Default.args, - hideLogo: false, + showLogo: true, }, }; @@ -121,6 +160,51 @@ export const AudioVideoEnabled: Story = { audioEnabled: true, videoEnabled: true, }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + + const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" }); + await userEvent.click(spotlightRadio); + await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight"); + + const micButtonMute = canvas.getByRole("switch", { + name: "Mute microphone", + }); + await userEvent.click(micButtonMute); + await expect(args.toggleAudio).toHaveBeenCalled(); + + const videoMuteButton = canvas.getByRole("switch", { + name: "Stop video", + }); + await userEvent.click(videoMuteButton); + await expect(args.toggleVideo).toHaveBeenCalled(); + const screenShare = canvas.getByRole("switch", { + name: "Share screen", + }); + await userEvent.click(screenShare); + await expect(args.toggleScreenSharing).toHaveBeenCalled(); + const endCall = canvas.getByRole("button", { + name: "End call", + }); + await userEvent.click(endCall); + await expect(args.hangup).toHaveBeenCalled(); + }, +}; + +/** used to test switching to grid mode */ +export const SpotlightMode: Story = { + ...Default, + args: { + ...Default.args, + layoutMode: "spotlight", + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + + const spotlightRadio = canvas.getByRole("radio", { name: "Grid" }); + await userEvent.click(spotlightRadio); + await expect(args.setLayoutMode).toHaveBeenCalledWith("grid"); + }, }; export const WithAudioOutputSpeaker: Story = { @@ -150,7 +234,37 @@ export const Pip: Story = { ...Default, args: { ...Default.args, - asPip: true, + buttonSize: "md", + layoutMode: undefined, + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + + await expect( + canvas.queryByRole("radio", { name: "Spotlight" }), + ).not.toBeInTheDocument(); + + const micButtonMute = canvas.getByRole("switch", { + name: "Mute microphone", + }); + await userEvent.click(micButtonMute); + await expect(args.toggleAudio).toHaveBeenCalled(); + + const videoMuteButton = canvas.getByRole("switch", { + name: "Stop video", + }); + await userEvent.click(videoMuteButton); + await expect(args.toggleVideo).toHaveBeenCalled(); + const screenShare = canvas.getByRole("switch", { + name: "Share screen", + }); + await userEvent.click(screenShare); + await expect(args.toggleScreenSharing).toHaveBeenCalled(); + const endCall = canvas.getByRole("button", { + name: "End call", + }); + await userEvent.click(endCall); + await expect(args.hangup).toHaveBeenCalled(); }, }; export const NoControlsWithLogo: Story = { @@ -158,7 +272,7 @@ export const NoControlsWithLogo: Story = { args: { ...Default.args, hideControls: true, - hideLogo: false, + showLogo: true, }, }; @@ -187,7 +301,7 @@ export const MobileLayout: Story = { ...Default, args: { ...Default.args, - hideLogo: true, + showLogo: false, audioOutputSwitcher: { targetOutput: "speaker", switch: fn() }, }, @@ -203,7 +317,7 @@ export const Lobby: Story = { ...Default, args: { ...Default.args, - hideLogo: true, + showLogo: false, openSettings: undefined, setLayoutMode: undefined, toggleScreenSharing: undefined, @@ -217,7 +331,7 @@ export const LobbyMobile: Story = { ...Default, args: { ...Default.args, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, @@ -235,7 +349,7 @@ export const LobbyRecentButton: Story = { args: { ...Default.args, children: Back To Recents, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, }, @@ -249,7 +363,7 @@ export const LobbyRecentButtonMobile: Story = { args: { ...Default.args, children: Back To Recents, - hideLogo: true, + showLogo: false, setLayoutMode: undefined, toggleScreenSharing: undefined, }, diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index afc5bdc9..7dd68d88 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -7,13 +7,12 @@ Please see LICENSE in the repository root for full details. import { type FC, type JSX, type Ref, useMemo } from "react"; import classNames from "classnames"; -import { BehaviorSubject } from "rxjs"; -import { Switch } from "@vector-im/compound-web"; -import { t } from "i18next"; import { SpotlightIcon, GridIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { Switch } from "@vector-im/compound-web"; +import { t } from "i18next"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -34,105 +33,123 @@ import { MediaMuteAndSwitchButton, type MenuOptions, } from "./MediaMuteAndSwitchButton"; +import { type ViewModel } from "../state/ViewModel"; +import { useBehavior } from "../useBehavior"; export interface AudioOutputSwitcher { targetOutput: string; switch: () => void; } -export interface FooterProps { - ref?: Ref; - /** Children will only be visible if the component is wider than 5*/ - children?: JSX.Element | JSX.Element[] | false; - - audioEnabled: boolean; +/** + * The Snapshot combines all fields required to populate the view. + * + * It is a combination of Actions and State. + * All Actions and State will be wrappen in behaviors. + * This has the advantage, that actions can mutate. + * (example: a device gets disconnected, the swicht action is not possible anymore, the actions becomes undefined) + * With it being reactive we can use the existance of the action to update the rendering without + * requiring additional state. + * + * Comment: It might not make sense to seperate the two interfaces. Hence the seperation + * just happens on the syntax level with the `type = ... & ...` notation. + */ +export type FooterSnapshot = FooterActions & FooterState; +export interface FooterActions { /** Also controls if the audioMute button is disabled */ toggleAudio: (() => void) | undefined; - videoEnabled: boolean; /** Also controls if the videoMute button is disabled */ toggleVideo: (() => void) | undefined; + toggleBlur: (() => void) | undefined; + /** Also controls if the layout button is visible */ + setLayoutMode: ((mode: GridMode) => void) | undefined; + toggleScreenSharing: (() => void) | undefined; + /** Also controls if the settings button is visible */ + openSettings: (() => void) | undefined; + /** Also controls if the hangup button is visible */ + hangup: (() => void) | undefined; +} +// we do not use any ? optional properties so that the vm type is including all fields. +export interface FooterState { + audioEnabled: boolean; + videoEnabled: boolean; + videoBlurEnabled: boolean; + showFooter: boolean; /* This is needed for WindowMode = "flat" */ - hideControls?: boolean; - /** hide the entire footer*/ - hidden?: boolean; - /** Pip controls buttonSize and hides: settings button, layout switcher and logo */ - asPip?: boolean; + hideControls: boolean; /** The footer should be used as an overlay. - * (Over the Call Grid) This saves spaces on small screens.*/ - asOverlay?: boolean; + * (Over the Call Grid) This saves spaces on small screens. */ + asOverlay: boolean; - layoutMode?: GridMode; - /** Also controls if the layout button is visible */ - setLayoutMode?: (mode: GridMode) => void; + buttonSize: "md" | "lg"; + showLogo: boolean; - sharingScreen?: boolean; - toggleScreenSharing?: () => void; + layoutMode: GridMode | undefined; - /** Also controls if the audio button is visible */ - audioOutputSwitcher?: AudioOutputSwitcher; - /** Also controls if the settings button is visible */ - openSettings?: () => void; - /** Also controls if the hangup button is visible */ - hangup?: () => void; + sharingScreen: boolean; - reactionIdentifier?: string; - reactionData?: ReactionData; + /** Also controls if the audio output button is visible */ + audioOutputSwitcher: AudioOutputSwitcher | undefined; + + reactionIdentifier: string | undefined; + reactionData: ReactionData | undefined; - hideLogo?: boolean; // debug stuff - debugTileLayout?: boolean; - tileStoreGeneration?: number; + debugTileLayout: boolean; + tileStoreGeneration: number | undefined; - audioOptions?: MenuOptions[]; - videoOptions?: MenuOptions[]; - selectedAudio?: string; - selectedVideo?: string; - selectAudioDevice?: (deviceId: string) => void; - selectVideoDevice?: (deviceId: string) => void; + /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ + audioOptions: MenuOptions[]; + /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ + videoOptions: MenuOptions[]; + selectedAudio: string | undefined; + selectedVideo: string | undefined; + selectAudioButtonOption: ((deviceId: string) => void) | undefined; + selectVideoButtonOption: ((option: string) => void) | undefined; } -export const CallFooter: FC = ({ - ref, - children, - asOverlay, - hidden, - hideControls, - hideLogo, - asPip, - layoutMode, - setLayoutMode, - openSettings, - audioEnabled, - videoEnabled, - toggleAudio, - toggleVideo, - sharingScreen, - toggleScreenSharing, - reactionIdentifier, - reactionData, - audioOutputSwitcher, - hangup, - debugTileLayout, - tileStoreGeneration, +export interface FooterProps { + ref?: Ref; + children?: JSX.Element | JSX.Element[] | false; + vm: ViewModel; +} +export const CallFooter: FC = ({ ref, children, vm }) => { + const asOverlay = useBehavior(vm.asOverlay$); + const showFooter = useBehavior(vm.showFooter$); + const hideControls = useBehavior(vm.hideControls$); + const layoutMode = useBehavior(vm.layoutMode$); + const setLayoutMode = useBehavior(vm.setLayoutMode$); + const openSettings = useBehavior(vm.openSettings$); + const audioEnabled = useBehavior(vm.audioEnabled$); + const videoEnabled = useBehavior(vm.videoEnabled$); + const toggleAudio = useBehavior(vm.toggleAudio$); + const toggleVideo = useBehavior(vm.toggleVideo$); + const sharingScreen = useBehavior(vm.sharingScreen$); + const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$); + const reactionIdentifier = useBehavior(vm.reactionIdentifier$); + const reactionData = useBehavior(vm.reactionData$); + const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); + const hangup = useBehavior(vm.hangup$); + const debugTileLayout = useBehavior(vm.debugTileLayout$); + const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$); + const videoOptions = useBehavior(vm.videoOptions$); + const selectedVideo = useBehavior(vm.selectedVideo$); + const audioOptions = useBehavior(vm.audioOptions$); + const selectedAudio = useBehavior(vm.selectedAudio$); + const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$); + const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$); + const toggleBlur = useBehavior(vm.toggleBlur$); + const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$); + const buttonSize = useBehavior(vm.buttonSize$); + const showLogo = useBehavior(vm.showLogo$); - audioOptions, - videoOptions, - selectedAudio, - selectedVideo, - selectAudioDevice, - selectVideoDevice, -}) => { const buttons: JSX.Element[] = []; - const buttonSize = asPip ? "md" : "lg"; - const showSettingsButton = - openSettings !== undefined && !asPip && !hideControls; - const showLayoutSwitcher = !asPip && !hideControls; - const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout); - const showLogo = !hideLogo && !asPip; - if (showSettingsButton) { - // add the settings button to the center group of buttons, so it will be visible on small screens. - // On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible. + + if (openSettings !== undefined) { + // Add the settings button to the center group so it's visible on small + // screens. On larger screens the SettingsIconButton with + // showForScreenWidth="wide" in the settingsLogoContainer is used instead. buttons.push( = ({ data-testid="incall_mute" options={audioOptions} selectedOption={selectedAudio} - onSelect={selectAudioDevice} + onSelect={selectAudioButtonOption} />, ); } else { @@ -169,6 +186,7 @@ export const CallFooter: FC = ({ />, ); } + if ((videoOptions?.length ?? 0) > 0) { buttons.push( = ({ iconsAndLabels="video" enabled={videoEnabled ?? false} onMuteClick={toggleVideo} - data-testid="incall_videomute" options={videoOptions} selectedOption={selectedVideo} - onSelect={selectVideoDevice} + onSelect={selectVideoButtonOption} + videoBlurToggleClick={toggleBlur} + videoBlurEnabled={videoBlurEnabled} />, ); } else { @@ -213,12 +232,7 @@ export const CallFooter: FC = ({ buttons.push( = ({ return (
- {showSettingsButton && ( + {openSettings !== undefined && ( = ({ /> )} {children} - {showLogoDebugContainer && logoDebugContainer} + {(showLogo || debugTileLayout) && logoDebugContainer}
{!hideControls &&
{buttons}
} - {setLayoutMode && layoutMode && showLayoutSwitcher && ( + {!hideControls && setLayoutMode && layoutMode && ( name="layoutMode" aria-label={t("layout_switch_label")} diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts new file mode 100644 index 00000000..ef3b756e --- /dev/null +++ b/src/components/CallFooterViewModel.test.ts @@ -0,0 +1,157 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, it, vi } from "vitest"; +import { BehaviorSubject } from "rxjs"; + +import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test"; +import { constant } from "../state/Behavior"; +import type { CallViewModel } from "../state/CallViewModel/CallViewModel"; +import type { Alignment, Layout } from "../state/layout-types"; +import type { SpotlightTileViewModel } from "../state/TileViewModel"; +import type { DeviceLabel } from "../state/MediaDevices"; +import { createCallFooterViewModel } from "./CallFooterViewModel"; + +const platformMock = vi.hoisted(() => vi.fn(() => "desktop")); +vi.mock("../Platform", () => ({ + get platform(): string { + return platformMock(); + }, +})); + +// Prevent supportsBackgroundProcessors from throwing in jsdom – it is not +// exercised by these tests (only used in `videoToggles`, not `videoOptions`). +vi.mock("@livekit/track-processors", () => ({ + supportsBackgroundProcessors: (): boolean => false, +})); + +/** + * Returns the minimum set of CallViewModel fields required by + * createCallFooterViewModel, with all other properties stubbed to + * simple constant values. + */ +function buildMinimalCallViewModel(layout: Layout): CallViewModel { + return { + layout$: constant(layout), + edgeToEdge$: constant(false), + showHeader$: constant(false), + hangup: (): void => {}, + gridMode$: constant("grid"), + setGridMode: (): void => {}, + sharingScreen$: constant(false), + toggleScreenSharing: null, + audioOutputSwitcher$: constant(null), + handsRaised$: constant({}), + reactions$: constant({}), + tileStoreGeneration$: constant(0), + showFooter$: constant(true), + settingsOpen$: constant(false), + setSettingsOpen$: constant(() => {}), + } as unknown as CallViewModel; +} + +/** A regular grid layout (not PiP). */ +const gridLayout: Layout = { + type: "grid", + grid: [], + spotlightAlignment$: new BehaviorSubject({ + inline: "end", + block: "end", + }), + setVisibleTiles: (_: number) => {}, +}; + +/** A PiP layout – only the `type` matters for the tests. */ +const pipLayout: Layout = { + type: "pip", + spotlight: {} as SpotlightTileViewModel, +}; + +const twoMicsAndOneCamMediaDevices = mockMediaDevices({ + audioInput: { + available$: constant( + new Map([ + ["mic1", { type: "number", number: 1 }], + ["mic2", { type: "name", name: "Microphone 2" }], + ]), + ), + selected$: constant(undefined), + select: vi.fn(), + }, + videoInput: { + available$: constant( + new Map([ + ["cam1", { type: "name", name: "Camera 1" }], + ]), + ), + selected$: constant(undefined), + select: vi.fn(), + }, +}); + +describe("createCallFooterViewModel", () => { + describe("audioOptions and videoOptions", () => { + function checkEmptyFor(platform: string, layout: Layout): void { + platformMock.mockReturnValue(platform); + + const vm = createCallFooterViewModel( + testScope(), + buildMinimalCallViewModel(layout), + mockMuteStates(), + twoMicsAndOneCamMediaDevices, + /* reactionIdentifier */ undefined, + ); + + expect(vm.audioOptions$.value).toEqual([]); + expect(vm.videoOptions$.value).toEqual([]); + } + it("are both empty when the platform is iOS", () => { + checkEmptyFor("ios", gridLayout); + }); + it("are both empty when the layout is pip", () => { + checkEmptyFor("desktop", pipLayout); + }); + + it("are populated when the platform is desktop and the layout is not PiP", () => { + platformMock.mockReturnValue("desktop"); + + const vm = createCallFooterViewModel( + testScope(), + buildMinimalCallViewModel(gridLayout), + mockMuteStates(), + twoMicsAndOneCamMediaDevices, + /* reactionIdentifier */ undefined, + ); + + expect(vm.audioOptions$?.value).toEqual([ + { + id: "mic1", + label: { + number: 1, + type: "number", + }, + }, + { + id: "mic2", + label: { + name: "Microphone 2", + type: "name", + }, + }, + ]); + expect(vm.videoOptions$?.value).toEqual([ + { + id: "cam1", + label: { + name: "Camera 1", + type: "name", + }, + }, + ]); + }); + }); +}); diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx new file mode 100644 index 00000000..ec4d4800 --- /dev/null +++ b/src/components/CallFooterViewModel.tsx @@ -0,0 +1,272 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { combineLatest, map, switchMap } from "rxjs"; +import { supportsBackgroundProcessors } from "@livekit/track-processors"; + +import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; +import { type MenuOptions } from "./MediaMuteAndSwitchButton"; +import { type MediaDevices } from "../state/MediaDevices"; +import { + backgroundBlur as backgroundBlurSettings, + debugTileLayout as debugTileLayoutSetting, +} from "../settings/settings"; +import { type Behavior, constant } from "../state/Behavior"; +import type { ObservableScope } from "../state/ObservableScope"; +import { type MuteStates } from "../state/MuteStates"; +import { createStaticViewModel, type ViewModel } from "../state/ViewModel"; +import { getUrlParams, HeaderStyle } from "../UrlParams"; +import { platform } from "../Platform"; +import { type FooterSnapshot } from "./CallFooter"; + +/** + * Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors + * needed by FooterSnapshot. + */ +function buildMuteBehaviors( + scope: ObservableScope, + muteStates: MuteStates, +): Pick< + ViewModel, + "audioEnabled$" | "toggleAudio$" | "videoEnabled$" | "toggleVideo$" +> { + return { + audioEnabled$: muteStates.audio.enabled$, + toggleAudio$: scope.behavior( + muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)), + ), + videoEnabled$: muteStates.video.enabled$, + toggleVideo$: scope.behavior( + muteStates.video.toggle$.pipe(map((t) => t ?? undefined)), + ), + }; +} + +/** + * Shared helper: maps MediaDevices into the audio/video device-list behaviors + * needed by FooterSnapshot (options, selection, callbacks, blur toggle). + */ +function buildDeviceBehaviors( + scope: ObservableScope, + mediaDevices: MediaDevices, + /** return empty arrays for audioOptions and videoOptions*/ + disableSwitcher$: Behavior, +): Pick< + ViewModel, + | "audioOptions$" + | "selectedAudio$" + | "selectAudioButtonOption$" + | "videoOptions$" + | "selectedVideo$" + | "selectVideoButtonOption$" + | "toggleBlur$" + | "videoBlurEnabled$" +> { + return { + audioOptions$: scope.behavior( + disableSwitcher$.pipe( + switchMap((disable) => + disable + ? constant([] as MenuOptions[]) + : mediaDevices.audioInput.available$.pipe( + map((available) => + [...available.entries()].map(([id, label]) => ({ + id, + label, + })), + ), + ), + ), + ), + ), + selectedAudio$: scope.behavior( + mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)), + ), + selectAudioButtonOption$: constant(mediaDevices.audioInput.select), + videoOptions$: scope.behavior( + disableSwitcher$.pipe( + switchMap((disable) => + disable + ? constant([] as MenuOptions[]) + : mediaDevices.videoInput.available$.pipe( + map((available) => + [...available.entries()].map(([id, label]) => ({ + id, + label, + })), + ), + ), + ), + ), + ), + selectedVideo$: scope.behavior( + mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)), + ), + selectVideoButtonOption$: constant(mediaDevices.videoInput.select), + toggleBlur$: scope.behavior( + combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe( + map(([current, switcherDisabled]) => { + return !switcherDisabled && supportsBackgroundProcessors() + ? (): void => { + backgroundBlurSettings.setValue(!current); + } + : undefined; + }), + ), + ), + videoBlurEnabled$: backgroundBlurSettings.value$, + }; +} + +/** + * Creates the ViewModel for the CallFooter. + * + * @param scope - ObservableScope that bounds the lifetime of derived behaviors. + * @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc. + * @param muteStates - Audio and video mute state + toggles. + * @param mediaDevices - Available and selected input devices. + * @param reactionIdentifier - The local user's reaction identifier string, or + * undefined when reactions are not supported (hides the reaction button). + */ +export function createCallFooterViewModel( + scope: ObservableScope, + callModel: CallViewModel, + muteStates: MuteStates, + mediaDevices: MediaDevices, + reactionIdentifier: string | undefined, +): ViewModel { + const { showControls, header: headerStyle } = getUrlParams(); + const showLogo = headerStyle === HeaderStyle.Standard; + + const isPip$ = scope.behavior( + callModel.layout$.pipe(map((l) => l.type === "pip")), + ); + const disableDeviceSwitcher$ = scope.behavior( + isPip$.pipe(map((isPip) => isPip || platform !== "desktop")), + ); + return { + ...buildMuteBehaviors(scope, muteStates), + ...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$), + // candidat to move into the FooterViewModel + showFooter$: callModel.showFooter$, + hideControls$: constant(!showControls), + asOverlay$: callModel.edgeToEdge$, + buttonSize$: scope.behavior( + isPip$.pipe(map((pip) => (pip ? "md" : "lg"))), + ), + + openSettings$: scope.behavior( + combineLatest([ + isPip$, + callModel.showHeader$, + callModel.setSettingsOpen$, + ]).pipe( + map(([isPip, showHeader, setSettingsOpen]) => + !isPip && + !(headerStyle === HeaderStyle.AppBar && showHeader) && + showControls + ? (): void => setSettingsOpen(true) + : undefined, + ), + ), + ), + + showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))), + + layoutMode$: callModel.gridMode$, + setLayoutMode$: scope.behavior( + isPip$.pipe( + map((isPip) => + !isPip && showControls ? callModel.setGridMode : undefined, + ), + ), + ), + + sharingScreen$: callModel.sharingScreen$, + toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined), + + audioOutputSwitcher$: scope.behavior( + callModel.audioOutputSwitcher$.pipe( + map((switcher) => switcher ?? undefined), + ), + ), + + hangup$: constant(callModel.hangup), + + reactionIdentifier$: constant(reactionIdentifier), + reactionData$: constant( + reactionIdentifier !== undefined + ? { + handsRaised$: callModel.handsRaised$, + reactions$: callModel.reactions$, + } + : undefined, + ), + + debugTileLayout$: debugTileLayoutSetting.value$, + tileStoreGeneration$: callModel.tileStoreGeneration$, + }; +} + +/** + * Creates a simplified ViewModel for the CallFooter used in the lobby + * (pre-call) screen. Unlike createCallFooterViewModel, this does not require + * a CallViewModel β€” it only needs mute states, device lists, and callbacks. + * + * @param scope - ObservableScope that bounds the lifetime of derived behaviors. + * @param muteStates - Audio and video mute state + toggles. + * @param mediaDevices - Available and selected input devices. + * @param openSettings - Callback to open the settings modal, or undefined. + * @param hangup - Callback to leave/cancel, or undefined (hides the button). + * @param showLogo - Whether to show the Element Call logo. + */ +export function createLobbyFooterViewModel( + scope: ObservableScope, + muteStates: MuteStates, + mediaDevices: MediaDevices, + openSettings: (() => void) | undefined, + hangup: (() => void) | undefined, + showLogo: boolean, +): ViewModel { + return { + ...createStaticViewModel({ + // we can safly skip any props that we do not need. + // The view model will then have less keys. + // But as soon as we call `useViewModel` and convert back to a snapshot the missing props will + // be correcty matching the snapshot type. + showLogo, + hideControls: false, + asOverlay: false, + buttonSize: "lg", + showLayoutSwitcher: false, + openSettings, + hangup, + debugTileLayout: false, + showFooter: true, + toggleAudio: undefined, + toggleVideo: undefined, + setLayoutMode: undefined, + toggleScreenSharing: undefined, + audioEnabled: undefined, + videoEnabled: undefined, + layoutMode: undefined, + sharingScreen: false, + audioOutputSwitcher: undefined, + reactionIdentifier: undefined, + reactionData: undefined, + tileStoreGeneration: undefined, + audioOptions: undefined, + videoOptions: undefined, + selectedAudio: undefined, + selectedVideo: undefined, + selectAudioButtonOption: undefined, + selectVideoButtonOption: undefined, + }), + ...buildMuteBehaviors(scope, muteStates), + ...buildDeviceBehaviors(scope, mediaDevices, constant(false)), + }; +} diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index bbf9f159..b014cf9b 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { fn, userEvent, within, expect } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -21,17 +20,11 @@ type Story = StoryObj; export const Default: Story = { args: { title: "SomeMenu", - iconsAndLabels: { - IconEnabled: AdvancedSettingsIcon, - IconDisabled: AdvancedSettingsIcon, - enabledLabel: "Enabled", - disabledLabel: "Disabled", - optionsButtonLabel: "Options", - }, + iconsAndLabels: "audio", enabled: true, options: [ - { label: "option 1", id: "1" }, - { label: "option 2", id: "2" }, + { label: { type: "name", name: "Option 1" }, id: "1" }, + { label: { type: "name", name: "Option 2" }, id: "2" }, ], selectedOption: "1", onMuteClick: fn(), @@ -46,23 +39,18 @@ export const AudioMute: Story = { iconsAndLabels: "audio", enabled: false, options: [ - { label: "Microphone 1", id: "1" }, - { label: "Microphone 2", id: "2" }, - ], - toggles: [ - { - label: "example toggle", - id: "t0", - enabled: true, - }, + { label: { type: "name", name: "Microphone 1" }, id: "1" }, + { label: { type: "name", name: "Microphone 2" }, id: "2" }, ], + videoBlurEnabled: true, + videoBlurToggleClick: fn(), selectedOption: "2", }, play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); // Both the mute button and the chevron trigger currently share the aria-label "Edit" // (both are TODO placeholders in the component). The mute button is first in the DOM. - const muteButton = canvas.getByLabelText("Unmute microphone"); + const muteButton = canvas.getByTestId("incall_mute"); await userEvent.click(muteButton); await expect(args.onMuteClick).toHaveBeenCalled(); }, @@ -74,10 +62,10 @@ export const AudioUnmute: Story = { iconsAndLabels: "audio", enabled: true, options: [ - { label: "Microphone 1", id: "1" }, - { label: "Microphone 2", id: "2" }, + { label: { type: "name", name: "Microphone 1" }, id: "1" }, + { label: { type: "name", name: "Microphone 2" }, id: "2" }, ], - toggles: [], + selectedOption: "2", }, }; @@ -88,10 +76,10 @@ export const VideoMute: Story = { iconsAndLabels: "video", enabled: false, options: [ - { label: "Camera 1", id: "1" }, - { label: "Camera 2", id: "2" }, + { label: { type: "name", name: "Camera 1" }, id: "1" }, + { label: { type: "name", name: "Camera 2" }, id: "2" }, ], - toggles: [], + selectedOption: "1", }, }; @@ -102,16 +90,11 @@ export const VideoUnmute: Story = { iconsAndLabels: "video", enabled: true, options: [ - { label: "Camera 1", id: "1" }, - { label: "Camera 2", id: "2" }, - ], - toggles: [ - { - label: "Blur Background", - id: "background_blurring", - enabled: false, - }, + { label: { type: "name", name: "Camera 1" }, id: "1" }, + { label: { type: "name", name: "Camera 2" }, id: "2" }, ], + videoBlurEnabled: true, + videoBlurToggleClick: fn(), selectedOption: "2", }, }; diff --git a/src/components/MediaMuteAndSwitchButton.test.tsx b/src/components/MediaMuteAndSwitchButton.test.tsx index 42a8d970..80ee0254 100644 --- a/src/components/MediaMuteAndSwitchButton.test.tsx +++ b/src/components/MediaMuteAndSwitchButton.test.tsx @@ -9,13 +9,16 @@ import { describe, expect, test, vi } from "vitest"; import { act, render, screen, type RenderResult } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { type JSX, useState } from "react"; +import { TooltipProvider } from "@vector-im/compound-web"; import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton"; describe("MediaMuteAndSwitchButton", () => { test("renders", () => { const { container } = render( - , + + + , ); expect(container).toMatchSnapshot(); }); @@ -26,11 +29,13 @@ describe("MediaMuteAndSwitchButton", () => { enabled: boolean, ): RenderResult => { return render( - , + + + , ); }; const renderAudioEndabled = renderLabels("audio", true); @@ -39,16 +44,16 @@ describe("MediaMuteAndSwitchButton", () => { const renderVideoDisabled = renderLabels("video", false); expect( - renderAudioEndabled.getByRole("button", { name: "Mute microphone" }), + renderAudioEndabled.getByRole("switch", { name: "Mute microphone" }), ).toBeInTheDocument(); expect( - renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }), + renderAudioDisabled.getByRole("switch", { name: "Unmute microphone" }), ).toBeInTheDocument(); expect( - renderVideoEnabled.getByRole("button", { name: "Start video" }), + renderVideoEnabled.getByRole("switch", { name: "Start video" }), ).toBeInTheDocument(); expect( - renderVideoDisabled.getByRole("button", { name: "Stop video" }), + renderVideoDisabled.getByRole("switch", { name: "Stop video" }), ).toBeInTheDocument(); }); @@ -56,15 +61,17 @@ describe("MediaMuteAndSwitchButton", () => { const user = userEvent.setup(); const onMute = vi.fn(); const { getByRole } = render( - , + + + , ); - await user.click(getByRole("button", { name: "Mute microphone" })); + await user.click(getByRole("switch", { name: "Mute microphone" })); expect(onMute).toHaveBeenCalled(); }); @@ -73,17 +80,19 @@ describe("MediaMuteAndSwitchButton", () => { const user = userEvent.setup(); const onSelect = vi.fn(); const { getByRole } = render( - , + + + , ); await user.click(getByRole("button", { name: "Microphone" })); @@ -95,17 +104,19 @@ describe("MediaMuteAndSwitchButton", () => { const user = userEvent.setup(); const onSelect = vi.fn(); const { getByRole } = render( - , + + + , ); await user.click(getByRole("button", { name: "Microphone" })); @@ -122,23 +133,25 @@ describe("MediaMuteAndSwitchButton", () => { function Wrapper(): JSX.Element { const [selectedOption, setSelectedOption] = useState("mic1"); return ( - { - onSelectPressed(); - void promise.then(() => { - setSelectedOption(id); - onOptionUpdated(); - }); - }} - /> + + { + onSelectPressed(); + void promise.then(() => { + setSelectedOption(id); + onOptionUpdated(); + }); + }} + /> + ); } @@ -174,42 +187,47 @@ describe("MediaMuteAndSwitchButton", () => { test("renders menu with toggle control and calls toggle callback", async () => { const user = userEvent.setup(); const onSelect = vi.fn(); + const onVideoBlurToggle = vi.fn(); const { getByRole } = render( - , + + + , ); - await user.click(getByRole("button", { name: "Microphone" })); + await user.click(getByRole("button", { name: "Camera" })); const toggle = screen.getByRole("menuitemcheckbox", { - name: "Background blur", + name: "Blur background", }); expect(toggle).toBeInTheDocument(); expect(toggle).toHaveAttribute("aria-checked", "false"); await user.click(toggle); - expect(onSelect).toHaveBeenCalledWith("bg_blur"); + expect(onVideoBlurToggle).toHaveBeenCalled(); }); test("renders check icon to mark the selected menu item", async () => { const user = userEvent.setup(); const { getByRole } = render( - , + + + , ); // open menu diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 7e38c7c6..44bdf5e6 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -12,45 +12,25 @@ import { MenuItem, ToggleMenuItem, } from "@vector-im/compound-web"; -import { t } from "i18next"; import { CheckIcon, ChevronUpIcon, ChevronDownIcon, - MicOffSolidIcon, MicOnIcon, - MicOnSolidIcon, SpinnerIcon, VideoCallIcon, - VideoCallOffSolidIcon, - VideoCallSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import classNames from "classnames"; -import { logger } from "matrix-js-sdk/lib/logger"; +import { useTranslation } from "react-i18next"; import styles from "./MediaMuteAndSwitchButton.module.css"; +import { MicButton, VideoButton } from "../button"; +import { type DeviceLabel } from "../state/MediaDevices"; export interface MenuOptions { - label: string; + label: DeviceLabel; id: string; } -export interface ToggleOption { - label: string; - enabled: boolean; - id: string; -} - -export interface IconsAndLabels { - /** The Icon used if the mute button is enabled */ - IconEnabled: ComponentType>; - /** The Icon used if the mute button is disabled */ - IconDisabled: ComponentType>; - /** The icon used for the different options */ - IconOptions?: ComponentType>; - enabledLabel: string; - disabledLabel: string; - optionsButtonLabel: string; -} export interface MediaMuteAndSwitchButtonProps { /** The title used in the Switcher modal. */ @@ -59,17 +39,13 @@ export interface MediaMuteAndSwitchButtonProps { enabled?: boolean; /** Callback if the mute button is clicked */ onMuteClick?: () => void; - iconsAndLabels?: "video" | "audio" | IconsAndLabels; + iconsAndLabels: "video" | "audio"; /** The options available for the media device selector modal */ options?: MenuOptions[]; /** The option that will currently be rendered as the selected option */ selectedOption?: string; - /** - * The available toggles (including there current state) - * The toggle state is not stored by this component. - * It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled` - */ - toggles?: ToggleOption[]; + videoBlurToggleClick?: () => void; + videoBlurEnabled?: boolean; /** * For any toggle and option this method will be called. * So toggles need to be implemented by listening here and setting the right toggle item to `enabled` @@ -77,70 +53,80 @@ export interface MediaMuteAndSwitchButtonProps { onSelect?: (id: string) => void; } +const BLUR_ID = "blur"; + export const MediaMuteAndSwitchButton: FC = ({ title, enabled, onMuteClick, - iconsAndLabels: iconsAndLabelsWithDefaultCases, + iconsAndLabels, options, selectedOption, - toggles, + videoBlurEnabled, + videoBlurToggleClick, onSelect, }) => { const [plannedSelection, setPlannedSelection] = useState(null); const [menuOpen, setMenuOpen] = useState(false); - let iconsAndLabels: IconsAndLabels | undefined; - switch (iconsAndLabelsWithDefaultCases) { + const { t } = useTranslation(); + let button; + let toggles: { label: string; enabled: boolean; id: string }[] = []; + switch (iconsAndLabels) { case "video": - iconsAndLabels = { - IconEnabled: VideoCallSolidIcon, - IconDisabled: VideoCallOffSolidIcon, - IconOptions: VideoCallIcon, - disabledLabel: t("stop_video_button_label"), - enabledLabel: t("start_video_button_label"), - optionsButtonLabel: t("settings.devices.microphone"), - }; + button = ( + { + onMuteClick?.(); + e.preventDefault(); + e.stopPropagation(); + }} + disabled={onMuteClick === undefined} + data-testid="incall_videomute" + /> + ); + if (videoBlurToggleClick !== undefined) { + toggles = [ + { + label: t("action.blur_background"), + enabled: videoBlurEnabled ?? false, + id: BLUR_ID, + }, + ]; + } break; case "audio": - iconsAndLabels = { - IconEnabled: MicOnSolidIcon, - IconDisabled: MicOffSolidIcon, - IconOptions: MicOnIcon, - disabledLabel: t("mute_microphone_button_label"), - enabledLabel: t("unmute_microphone_button_label"), - optionsButtonLabel: t("settings.devices.microphone"), - }; - break; - default: - iconsAndLabels = iconsAndLabelsWithDefaultCases; + button = ( + { + onMuteClick?.(); + e.preventDefault(); + e.stopPropagation(); + }} + disabled={onMuteClick === undefined} + data-testid="incall_mute" + /> + ); break; } - const { - IconEnabled, - IconDisabled, - IconOptions, - disabledLabel, - enabledLabel, - optionsButtonLabel, - } = iconsAndLabels ?? { - IconEnabled: undefined, - IconDisabled: undefined, - IconOptions: undefined, - disabledLabel: undefined, - enabledLabel: undefined, - optionsButtonLabel: undefined, - }; - { - logger.info( - "RENDER WITH: selectedOption !== option.id && plannedSelection === option.id", - selectedOption, - " !==", - "option.id", - " && ", - plannedSelection, - " === ", - "option.id", - ); + + let IconOptions: ComponentType> | undefined; + let optionsButtonLabel: string; + let numberedLabel: (number: number) => string; + switch (iconsAndLabels) { + case "video": + IconOptions = VideoCallIcon; + optionsButtonLabel = t("settings.devices.camera"); + numberedLabel = (n): string => + t("settings.devices.microphone_numbered", { n }); + break; + case "audio": + IconOptions = MicOnIcon; + optionsButtonLabel = t("settings.devices.microphone"); + numberedLabel = (n): string => + t("settings.devices.camera_numbered", { n }); + break; } return (
= ({ })} > {/* The mute button lives inside */} -
diff --git a/src/grid/SpotlightLandscapeLayout.tsx b/src/grid/SpotlightLandscapeLayout.tsx index d87be1f1..d76890c5 100644 --- a/src/grid/SpotlightLandscapeLayout.tsx +++ b/src/grid/SpotlightLandscapeLayout.tsx @@ -22,7 +22,7 @@ import { useUpdateLayout, useVisibleTiles } from "./Grid"; export const makeSpotlightLandscapeLayout: CallLayout< SpotlightLandscapeLayoutModel > = ({ minBounds$ }) => ({ - scrollingOnTop: false, + foreground: "scrolling", fixed: function SpotlightLandscapeLayoutFixed({ ref, diff --git a/src/grid/SpotlightPortraitLayout.tsx b/src/grid/SpotlightPortraitLayout.tsx index a6d1241c..6939e082 100644 --- a/src/grid/SpotlightPortraitLayout.tsx +++ b/src/grid/SpotlightPortraitLayout.tsx @@ -29,7 +29,7 @@ interface GridCSSProperties extends CSSProperties { export const makeSpotlightPortraitLayout: CallLayout< SpotlightPortraitLayoutModel > = ({ minBounds$ }) => ({ - scrollingOnTop: false, + foreground: "fixed", fixed: function SpotlightPortraitLayoutFixed({ ref, diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 6ac08422..2aef571a 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -25,7 +25,9 @@ import { type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; import { BrowserRouter } from "react-router-dom"; -import userEvent from "@testing-library/user-event"; +import userEvent, { + PointerEventsCheckLevel, +} from "@testing-library/user-event"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { useState } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -395,7 +397,11 @@ test("user can reconnect after a membership manager error", async () => { // async state update should be processed automatically by the waitFor call), // and yet here we are. await act(async () => - user.click(screen.getByRole("button", { name: "Reconnect" })), + user + // With css vitest turned on this test thinks that the button has pointer_events: none;. + // TODO investigate if this is a test setup issue or an actual problem. + .setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }) + .click(screen.getByRole("button", { name: "Reconnect" })), ); // In-call controls should be visible again await waitFor(() => screen.getByRole("button", { name: "Leave" })); diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 390d6058..fcf1a492 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -26,6 +26,33 @@ Please see LICENSE in the repository root for full details. ); } +.header.hidden { + display: none; +} + +.header.overlay { + /* Note that the header is still position: sticky in this case so that certain + tiles can move down out of the way of the header when visible. */ + opacity: 1; + transition: opacity 0.15s; +} + +.header.overlay.hidden { + display: flex; + opacity: 0; + pointer-events: none; + /* Switch to position: absolute so the header takes up no space in the layout + when hidden. */ + position: absolute; + inset-block-start: 0; + inset-inline: 0; +} + +.header.overlay:has(:focus-visible) { + opacity: 1; + pointer-events: initial; +} + .header.filler { block-size: var(--cpd-space-6x); background: none; diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index c23a9dcb..eb210457 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details. */ import { + afterEach, beforeEach, describe, expect, @@ -14,10 +15,15 @@ import { type MockedFunction, vi, } from "vitest"; -import { render, type RenderResult } from "@testing-library/react"; +import { + render, + type RenderResult, + getByRole, + screen, +} from "@testing-library/react"; import { type LocalParticipant } from "livekit-client"; import { BehaviorSubject, of } from "rxjs"; -import { BrowserRouter, MemoryRouter } from "react-router-dom"; +import { BrowserRouter } from "react-router-dom"; import { TooltipProvider } from "@vector-im/compound-web"; import { RoomContext, useLocalParticipant } from "@livekit/components-react"; import userEvent from "@testing-library/user-event"; @@ -34,7 +40,10 @@ import { } from "../utils/test"; import { E2eeType } from "../e2ee/e2eeType"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; -import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel"; +import { + type CallViewModel, + type CallViewModelOptions, +} from "../state/CallViewModel/CallViewModel"; import { alice, local } from "../utils/test-fixtures"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; @@ -100,13 +109,12 @@ beforeEach(() => { interface CreateInCallViewArgs { mediaDevices?: ECMediaDevices; callViewModelOptions?: Partial; - /** If set, uses a MemoryRouter with this as the initial entry instead of BrowserRouter */ - initialRoute?: string; /** If true, wraps the rendered tree in an AppBar provider */ withAppBar?: boolean; } function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { rtcSession: MockRTCSession; + vm: CallViewModel; } { const mediaDevices = args.mediaDevices ?? mockMediaDevices({}); const muteState = mockMuteStates(); @@ -118,7 +126,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { remoteParticipants$: of([remoteParticipant]), }, ); - const { vm, rtcSession } = getBasicCallViewModelEnvironment( + const { vm, footerVm, rtcSession } = getBasicCallViewModelEnvironment( [local, alice], undefined, mediaDevices, @@ -129,20 +137,13 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & { const room = rtcSession.room; const client = room.client; - const Router = args.initialRoute - ? ({ children }: { children: React.ReactNode }): React.ReactNode => ( - - {children} - - ) - : BrowserRouter; - const inCallView = ( {inCallView} : inCallView; const renderResult = render( - + - , + , ); return { ...renderResult, rtcSession, + vm, }; } @@ -189,57 +191,46 @@ describe("InCallView", () => { expect(container).toMatchSnapshot(); }); }); + describe("settings button with AppBar header", () => { - it("mobile landscape, is accessible when showHeader is false", () => { - // windowSize with height <= 600 results in "flat" windowMode, - // which means showHeader$ emits false. - const { getAllByRole } = createInCallView({ - initialRoute: "/?header=app_bar", - withAppBar: true, - callViewModelOptions: { - // Set windowMode$ to "flat" (height <= 600) - windowSize$: constant({ width: 1000, height: 500 }), - }, - }); - // When showHeader is false, hideSettingsButton is false, - // so the settings button is visible in the footer. - const settingsBtn = getAllByRole("button", { name: "Settings" }); - // here we check for two settings buttons because there are two buttons in the bottom bar. One for the - // the narrow layout and another one for the wide layout. - // Their visibility uses @media css queries, which cannot be tested in JSDOM, - // but we can at least check that both buttons are rendered and have the correct classes. - expect(settingsBtn.length).toBe(2); - expect(settingsBtn[0]).toHaveAttribute( - "data-testid", - "settings-bottom-left", - ); - expect(settingsBtn[0]).toBeVisible(); + beforeEach(() => { + // getUrlParams() reads window.location directly rather than from the + // React Router context, so MemoryRouter alone is not enough to make + // it see "header=app_bar". Push the real URL so both paths agree. + window.history.pushState({}, "", "?header=app_bar"); }); - it("mobile portrait, is accessible when showHeader is true", () => { - // windowSize with height > 600 and width > 600 results in "normal" windowMode, - // which means showHeader$ emits true. - const { getAllByRole } = createInCallView({ - initialRoute: "/?header=app_bar", + afterEach(() => { + window.history.pushState({}, "", "/"); + }); + + it("mobile portrait, is visible in the header", () => { + createInCallView({ withAppBar: true, callViewModelOptions: { - // Set windowMode$ to "normal" (height >= 600) - windowSize$: constant({ width: 1000, height: 800 }), + // Narrow like a mobile phone in portrait orientation + windowSize$: constant({ width: 400, height: 700 }), }, }); - // When showHeader is true and headerStyle is AppBar, - // hideSettingsButton is true in the footer, but the settings - // button is rendered in the AppBar via useAppBarSecondaryButton. - const settingsBtns = getAllByRole("button", { name: "Settings" }); - expect(settingsBtns.length).toBe(1); - expect(settingsBtns[0]).toHaveAttribute( - "data-testid", - "settings-app-bar", - ); - expect(settingsBtns[0]).toBeVisible(); + getByRole(screen.getByRole("banner"), "button", { + name: "Settings", + }); + }); + + it("mobile landscape, is not visible anywhere", () => { + const { queryByRole } = createInCallView({ + withAppBar: true, + callViewModelOptions: { + // Flat like a mobile phone in landscape orientation + windowSize$: constant({ width: 700, height: 400 }), + }, + }); + + expect(queryByRole("button", { name: "Settings" })).not.toBeVisible(); }); }); + describe("audioOutputSwitcher", () => { it("is visible and can be clicked", async () => { const user = userEvent.setup(); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 7fc97e27..e704eb39 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details. import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk"; import { type FC, - type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, useCallback, useEffect, @@ -21,7 +20,7 @@ import { import useMeasure from "react-use-measure"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import classNames from "classnames"; -import { BehaviorSubject, map } from "rxjs"; +import { map } from "rxjs"; import { useObservable } from "observable-hooks"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { useTranslation } from "react-i18next"; @@ -43,7 +42,6 @@ import { InviteButton } from "../button/InviteButton"; import { type CallViewModel, createCallViewModel$, - type GridMode, } from "../state/CallViewModel/CallViewModel.ts"; import { Grid, type TileProps } from "../grid/Grid"; import { useInitial } from "../useInitial"; @@ -51,12 +49,9 @@ import { SpotlightTile } from "../tile/SpotlightTile"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { E2eeType } from "../e2ee/e2eeType"; import { makeGridLayout } from "../grid/GridLayout"; -import { - type CallLayoutOutputs, - defaultPipAlignment, - defaultSpotlightAlignment, -} from "../grid/CallLayout"; -import { makeOneOnOneLayout } from "../grid/OneOnOneLayout"; +import { type CallLayoutOutputs } from "../grid/CallLayout"; +import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout"; +import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; @@ -68,11 +63,7 @@ import { import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { ReactionsOverlay } from "./ReactionsOverlay"; import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; -import { - debugTileLayout as debugTileLayoutSetting, - matrixRTCMode as matrixRTCModeSetting, - useSetting, -} from "../settings/settings"; +import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings"; import { ReactionsReader } from "../reactions/ReactionsReader"; import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; @@ -90,14 +81,23 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t import { type Layout } from "../state/layout-types.ts"; import { ObservableScope } from "../state/ObservableScope.ts"; import { useLatest } from "../useLatest.ts"; -import { CallFooter } from "../components/CallFooter.tsx"; +import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx"; import { SettingsIconButton } from "../button/Button.tsx"; +import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx"; +import { type ViewModel } from "../state/ViewModel.ts"; + +declare module "react" { + interface CSSProperties { + "--call-view-safe-area-inset-top"?: string; + "--call-view-safe-area-inset-bottom"?: string; + } +} const logger = rootLogger.getChild("[InCallView]"); export interface ActiveCallProps extends Omit< InCallViewProps, - "vm" | "livekitRoom" | "connState" + "vm" | "livekitRoom" | "connState" | "footerVm" > { e2eeSystem: EncryptionSystem; // TODO refactor those reasons into an enum @@ -108,7 +108,9 @@ export interface ActiveCallProps extends Omit< export const ActiveCall: FC = (props) => { const [vm, setVm] = useState(null); - + const [footerVm, setFooterVm] = useState | null>( + null, + ); const urlParams = useUrlParams(); const mediaDevices = useMediaDevices(); const trackProcessorState$ = useTrackProcessorObservable$(); @@ -118,6 +120,7 @@ export const ActiveCall: FC = (props) => { const reactionsReader = new ReactionsReader(scope, props.rtcSession); const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } = urlParams; + const vm = createCallViewModel$( scope, props.rtcSession, @@ -141,7 +144,6 @@ export const ActiveCall: FC = (props) => { vm.leave$.pipe(scope.bind()).subscribe(props.onLeft); return (): void => { - logger.info("END CALL VIEW SCOPE"); scope.end(); }; }, [ @@ -153,13 +155,44 @@ export const ActiveCall: FC = (props) => { urlParams, mediaDevices, trackProcessorState$, + props.client, + ]); + + useEffect(() => { + if (vm === null) return; + + const scope = new ObservableScope(); + const footerVm = createCallFooterViewModel( + scope, + vm, + props.muteStates, + mediaDevices, + `${props.client.getUserId()}:${props.client.getDeviceId()}`, + ); + setFooterVm(footerVm); + + return (): void => { + scope.end(); + }; + }, [ + props.rtcSession, + props.matrixRoom, + props.muteStates, + props.e2eeSystem, + props.onLeft, + urlParams, + mediaDevices, + trackProcessorState$, + props.client, + vm, ]); if (vm === null) return null; + if (footerVm === null) return null; return ( - + ); }; @@ -167,6 +200,7 @@ export const ActiveCall: FC = (props) => { export interface InCallViewProps { client: MatrixClient; vm: CallViewModel; + footerVm: ViewModel; matrixInfo: MatrixInfo; rtcSession: MatrixRTCSession; matrixRoom: MatrixRoom; @@ -177,14 +211,14 @@ export interface InCallViewProps { export const InCallView: FC = ({ client, vm, + footerVm, matrixInfo, matrixRoom, muteStates, onShareClick, }) => { const { t } = useTranslation(); - const { supportsReactions, sendReaction, toggleRaisedHand } = - useReactionsSender(); + const { sendReaction, toggleRaisedHand } = useReactionsSender(); useWakeLock(); // TODO-MULTI-SFU This is unused now?? @@ -220,9 +254,6 @@ export const InCallView: FC = ({ muted: muteAllAudio, }); const latestPickupPhaseAudio = useLatest(pickupPhaseAudio); - - const audioEnabled = useBehavior(muteStates.audio.enabled$); - const videoEnabled = useBehavior(muteStates.video.enabled$); const toggleAudio = useBehavior(muteStates.audio.toggle$); const toggleVideo = useBehavior(muteStates.video.toggle$); const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$); @@ -239,16 +270,14 @@ export const InCallView: FC = ({ const audioParticipants = useBehavior(vm.livekitRoomItems$); const participantCount = useBehavior(vm.participantCount$); const reconnecting = useBehavior(vm.reconnecting$); - const windowMode = useBehavior(vm.windowMode$); const layout = useBehavior(vm.layout$); - const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$); - const [debugTileLayout] = useSetting(debugTileLayoutSetting); - const gridMode = useBehavior(vm.gridMode$); + const edgeToEdge = useBehavior(vm.edgeToEdge$); + const showNameTags = useBehavior(vm.showNameTags$); const showHeader = useBehavior(vm.showHeader$); - const showFooter = useBehavior(vm.showFooter$); + const settingsOpen = useBehavior(vm.settingsOpen$); + const setSettingsOpen = useBehavior(vm.setSettingsOpen$); const earpieceMode = useBehavior(vm.earpieceMode$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); - const sharingScreen = useBehavior(vm.sharingScreen$); const fatalCallError = useBehavior(vm.fatalError$); // Stop the rendering and throw for the error boundary @@ -273,10 +302,13 @@ export const InCallView: FC = ({ } }, [ringing, latestPickupPhaseAudio]); - const onViewClick = useCallback( - (e: ReactMouseEvent) => { + // iOS Safari doesn't reliably fire `click` on plain
s, so we listen + // for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`, + // so this still only fires for taps. + const onViewPointerUp = useCallback( + (e: ReactPointerEvent) => { if ( - (e.nativeEvent as PointerEvent).pointerType === "touch" && + e.pointerType === "touch" && // If an interactive element was tapped, don't count this as a tap on the screen (e.target as Element).closest?.("button, input") === null ) @@ -293,28 +325,18 @@ export const InCallView: FC = ({ ); const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]); - const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); - const openSettings = useCallback( - () => setSettingsModalOpen(true), - [setSettingsModalOpen], - ); - const closeSettings = useCallback( - () => setSettingsModalOpen(false), - [setSettingsModalOpen], - ); - const openProfile = useMemo( () => // Profile settings are unavailable in widget mode widget === null ? (): void => { setSettingsTab("profile"); - setSettingsModalOpen(true); + setSettingsOpen(true); } : null, - [setSettingsTab, setSettingsModalOpen], + [setSettingsTab, setSettingsOpen], ); const [headerRef, headerBounds] = useMeasure(); @@ -325,15 +347,14 @@ export const InCallView: FC = ({ width: bounds.width, height: bounds.height - - headerBounds.height - - (windowMode === "flat" ? 0 : footerBounds.height), + (edgeToEdge ? 0 : headerBounds.height + footerBounds.height), }), [ bounds.width, bounds.height, headerBounds.height, footerBounds.height, - windowMode, + edgeToEdge, ], ); const gridBoundsObservable$ = useObservable( @@ -341,64 +362,50 @@ export const InCallView: FC = ({ [gridBounds], ); - const spotlightAlignment$ = useInitial( - () => new BehaviorSubject(defaultSpotlightAlignment), - ); - const pipAlignment$ = useInitial( - () => new BehaviorSubject(defaultPipAlignment), - ); - - const setGridMode = useCallback( - (mode: GridMode) => vm.setGridMode(mode), - [vm], - ); - useAppBarHidden(!showHeader); let header: ReactNode = null; - if (showHeader) { - switch (headerStyle) { - case HeaderStyle.AppBar: { - // dont build a header here. The AppBar will take care of it. - break; - } - case HeaderStyle.None: - // Cosmetic header to fill out space while still affecting the bounds of - // the grid - header = ( -
- ); - break; - case HeaderStyle.Standard: - header = ( -
- - - - - {showControls && onShareClick !== null && ( - - )} - -
- ); + switch (headerStyle) { + case HeaderStyle.AppBar: { + // dont build a header here. The AppBar will take care of it. + break; } + case HeaderStyle.None: + // Cosmetic header to fill out space while still affecting the bounds of + // the grid + header = showHeader && ( +
+ ); + break; + case HeaderStyle.Standard: + header = ( +
+ + + + + {showControls && onShareClick !== null && ( + + )} + +
+ ); } // The reconnecting toast cannot be dismissed @@ -445,12 +452,11 @@ export const InCallView: FC = ({ }: TileProps): ReactNode { const spotlightExpanded = useBehavior(vm.spotlightExpanded$); const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$); - const showSpeakingIndicatorsValue = useBehavior( - vm.showSpeakingIndicators$, - ); - const showSpotlightIndicatorsValue = useBehavior( + const showSpotlightIndicators = useBehavior( vm.showSpotlightIndicators$, ); + const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$); + const showNameTags = useBehavior(vm.showNameTags$); return model instanceof GridTileViewModel ? ( = ({ targetHeight={targetHeight} className={classNames(className, styles.tile)} style={style} - showSpeakingIndicators={showSpeakingIndicatorsValue} + showSpeakingIndicators={showSpeakingIndicators} + showNameTags={showNameTags} focusable={!contentObscured} /> ) : ( @@ -472,7 +479,8 @@ export const InCallView: FC = ({ onToggleExpanded={onToggleExpanded} targetWidth={targetWidth} targetHeight={targetHeight} - showIndicators={showSpotlightIndicatorsValue} + showIndicators={showSpotlightIndicators} + showNameTags={showNameTags} focusable={!contentObscured} className={classNames(className, styles.tile)} style={style} @@ -483,20 +491,18 @@ export const InCallView: FC = ({ ); const layouts = useMemo(() => { - const inputs = { - minBounds$: gridBoundsObservable$, - spotlightAlignment$, - pipAlignment$, - }; + const inputs = { minBounds$: gridBoundsObservable$ }; return { grid: makeGridLayout(inputs), "spotlight-landscape": makeSpotlightLandscapeLayout(inputs), "spotlight-portrait": makeSpotlightPortraitLayout(inputs), "spotlight-expanded": makeSpotlightExpandedLayout(inputs), - "one-on-one": makeOneOnOneLayout(inputs), + "one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs), + "one-on-one-portrait": makeOneOnOnePortraitLayout(inputs), }; - }, [gridBoundsObservable$, spotlightAlignment$, pipAlignment$]); + }, [gridBoundsObservable$]); + const showFooter = useBehavior(footerVm.showFooter$); const renderContent = (): JSX.Element => { if (layout.type === "pip") { return ( @@ -508,6 +514,7 @@ export const InCallView: FC = ({ targetWidth={gridBounds.width} targetHeight={gridBounds.height} showIndicators={false} + showNameTags={showNameTags} focusable={!contentObscured} aria-hidden={contentObscured} /> @@ -520,9 +527,26 @@ export const InCallView: FC = ({ key="fixed" className={styles.fixedGrid} style={{ - insetBlockStart: - headerBounds.height > 0 ? headerBounds.bottom : bounds.top, - height: gridBounds.height, + // If not edge-to-edge, consume the header insets right here. + insetBlockStart: edgeToEdge ? 0 : bounds.top + headerBounds.height, + height: edgeToEdge ? "100%" : gridBounds.height, + // If edge-to-edge, compute new safe area insets that account for the + // header and footer, passing them down to the tiles. + "--call-view-safe-area-inset-top": + edgeToEdge && headerStyle !== HeaderStyle.None && showHeader + ? // Header has two relevant cases: if it's an app bar, it lives + // outside the InCallView and consumes the safe area insets + // itself. Otherwise account for the safe area and header size + // as part of the InCallView. + headerStyle === HeaderStyle.AppBar + ? `${bounds.top}px` + : `calc(env(safe-area-inset-top) + ${headerBounds.height}px)` + : undefined, + "--call-view-safe-area-inset-bottom": + edgeToEdge && showFooter + ? // Footer always lives inside the InCallView. + `calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)` + : undefined, }} model={layout} Layout={layers.fixed} @@ -540,75 +564,52 @@ export const InCallView: FC = ({ aria-hidden={contentObscured} /> ); - // The grid tiles go *under* the spotlight in the portrait layout, but - // *over* the spotlight in the expanded layout - return layout.type === "spotlight-expanded" ? ( - <> - {fixedGrid} - {scrollingGrid} - - ) : ( - <> - {scrollingGrid} - {fixedGrid} - - ); + + // Put the right layer in the foreground for the requested layout + switch (layers.foreground) { + case "fixed": + return ( + <> + {scrollingGrid} + {fixedGrid} + + ); + case "scrolling": + return ( + <> + {fixedGrid} + {scrollingGrid} + + ); + } }; const rageshakeRequestModalProps = useRageshakeRequestModal( matrixRoom.roomId, ); - const settingsButtonInAppBar = - headerStyle === HeaderStyle.AppBar && showHeader; useAppBarSecondaryButton( setSettingsOpen(true)} data-testid="settings-app-bar" />, ); // Only hide the settings button if we have an AppBar header and we are showing the header - const footer = ( -
{client && (
@@ -164,6 +165,7 @@ exports[`InCallView > rendering > renders 1`] = ` /> + + Back to recents + +
+ +
+
+`; diff --git a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap index 8d43b12b..2400a535 100644 --- a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap +++ b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap @@ -410,9 +410,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` local )

-
+    
       {
   "region": "local",
   "version": "1.2.3"
@@ -422,9 +420,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
     

Local Participant

-
+    
       localParticipantIdentity
     

@@ -451,9 +447,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` remote )

-
+    
       {
   "region": "remote",
   "version": "4.5.6"
@@ -463,9 +457,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
     

Local Participant

-
+    
       localParticipantIdentity
     

diff --git a/src/settings/rageshake.ts b/src/settings/rageshake.ts index c288f73e..3cc6c2b4 100644 --- a/src/settings/rageshake.ts +++ b/src/settings/rageshake.ts @@ -502,6 +502,13 @@ export async function init(): Promise { }; }); + window.addEventListener("unhandledrejection", (event) => { + global.mx_rage_logger.log( + LogLevel.error, + `Unhandled promise rejection: ${event.reason}`, + ); + }); + return tryInitStorage(); } diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index cb528f68..fc25df48 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -50,6 +50,7 @@ import { aliceParticipant, aliceRtcMember, aliceUserId, + bob, bobId, bobRtcMember, local, @@ -84,6 +85,14 @@ vi.mock("../e2ee/matrixKeyProvider"); const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); vi.mock("../UrlParams", () => ({ getUrlParams })); +const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); +vi.mock("../../Platform", () => ({ + get platform(): string { + return getPlatform(); + }, + isFirefox: (): boolean => false, +})); + vi.mock( "../state/CallViewModel/localMember/localTransport", async (importOriginal) => ({ @@ -133,12 +142,19 @@ export interface SpotlightExpandedLayoutSummary { pip?: string; } -export interface OneOnOneLayoutSummary { - type: "one-on-one"; +export interface OneOnOneLandscapeLayoutSummary { + type: "one-on-one-landscape"; spotlight: string; pip: string; } +export interface OneOnOnePortraitLayoutSummary { + type: "one-on-one-portrait"; + spotlight: string[]; + pip?: string; + pipSize: "sm" | "lg"; +} + export interface PipLayoutSummary { type: "pip"; spotlight: string[]; @@ -149,7 +165,8 @@ export type LayoutSummary = | SpotlightLandscapeLayoutSummary | SpotlightPortraitLayoutSummary | SpotlightExpandedLayoutSummary - | OneOnOneLayoutSummary + | OneOnOneLandscapeLayoutSummary + | OneOnOnePortraitLayoutSummary | PipLayoutSummary; function summarizeLayout$(l$: Observable): Observable { @@ -187,7 +204,7 @@ function summarizeLayout$(l$: Observable): Observable { pip: pip?.id, }), ); - case "one-on-one": + case "one-on-one-landscape": return combineLatest( [l.spotlight.media$, l.pip.media$], (spotlight, pip) => ({ @@ -196,6 +213,20 @@ function summarizeLayout$(l$: Observable): Observable { pip: pip.id, }), ); + case "one-on-one-portrait": + return combineLatest( + [ + l.spotlight.media$, + l.pip?.media$ ?? constant(undefined), + l.pipSize$, + ], + (spotlight, pip, pipSize) => ({ + type: l.type, + spotlight: spotlight.map((vm) => vm.id), + pip: pip?.id, + pipSize, + }), + ); case "pip": return l.spotlight.media$.pipe( map((spotlight) => ({ @@ -405,7 +436,7 @@ describe.each([ expectedLayoutMarbles, { a: { - type: "one-on-one", + type: "one-on-one-landscape", pip: `${localId}:0`, spotlight: `${aliceId}:0`, }, @@ -421,6 +452,85 @@ describe.each([ }); }); + test("one-on-one portrait layout shows local tile when video is enabled", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + // Local participant enables their video, then disables it + const videoInputMarbles = " ny--n"; + // While tile is shown, tap the screen twice + const tapScreenInputMarbles = "--aa-"; + // Layout should show local tile, make it small, enlarge it again, then hide it + const expectedLayoutMarbles = "abcba"; + + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant]), + roomMembers: [local, alice], + rtcMembers$: constant([localRtcMember, aliceRtcMember]), + videoEnabled: new Map([ + [localParticipant, behavior(videoInputMarbles, yesNo)], + ]), + windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait + }, + (vm) => { + schedule(tapScreenInputMarbles, { a: () => vm.tapScreen() }); + + expectObservable(vm.edgeToEdge$).toBe("y", yesNo); // Edge-to-edge-layout + expectObservable(summarizeLayout$(vm.layout$)).toBe( + expectedLayoutMarbles, + { + a: { + type: "one-on-one-portrait", + spotlight: [`${aliceId}:0`], + pip: undefined, + pipSize: "lg", + }, + b: { + type: "one-on-one-portrait", + spotlight: [`${aliceId}:0`], + pip: `${localId}:0`, + pipSize: "lg", + }, + c: { + type: "one-on-one-portrait", + spotlight: [`${aliceId}:0`], + pip: `${localId}:0`, + pipSize: "sm", + }, + }, + ); + }, + ); + }); + }); + + test("one-on-one portrait layout shows name tags in room with 3 members", () => { + withTestScheduler(({ behavior, schedule, expectObservable }) => { + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant]), + // Both Alice and Bob are with us in the room + roomMembers: [local, alice, bob], + rtcMembers$: constant([localRtcMember, aliceRtcMember]), + windowSize$: constant({ width: 380, height: 700 }), // Mobile phone in portrait + }, + (vm) => { + // Uses one-on-one portrait layout + expectObservable(summarizeLayout$(vm.layout$)).toBe("a", { + a: { + type: "one-on-one-portrait", + spotlight: [`${aliceId}:0`], + pip: undefined, + pipSize: "lg", + }, + }); + // It wouldn't be clear whether Alice or Bob is the remote video tile, + // so the interface must put a name tag on it + expectObservable(vm.showNameTags$).toBe("y", yesNo); + }, + ); + }); + }); + test("participants stay in the same order unless to appear/disappear", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { const visibilityInputMarbles = "a"; @@ -576,7 +686,7 @@ describe.each([ }); test("layout reacts to window size", () => { - withTestScheduler(({ behavior, schedule, expectObservable }) => { + withTestScheduler(({ behavior, expectObservable }) => { const windowSizeInputMarbles = "abc"; const expectedLayoutMarbles = " abc"; withCallViewModel( @@ -584,7 +694,7 @@ describe.each([ remoteParticipants$: constant([aliceParticipant]), rtcMembers$: constant([localRtcMember, aliceRtcMember]), windowSize$: behavior(windowSizeInputMarbles, { - a: { width: 300, height: 600 }, // Start very narrow, like a phone + a: { width: 380, height: 700 }, // Start very narrow, like a phone b: { width: 1000, height: 800 }, // Go to normal desktop window size c: { width: 200, height: 180 }, // Go to PiP size }), @@ -595,13 +705,14 @@ describe.each([ { a: { // This is the expected one-on-one layout for a narrow window - type: "spotlight-expanded", + type: "one-on-one-portrait", spotlight: [`${aliceId}:0`], - pip: `${localId}:0`, + pip: undefined, + pipSize: "lg", }, b: { // In a larger window, expect the normal one-on-one layout - type: "one-on-one", + type: "one-on-one-landscape", pip: `${localId}:0`, spotlight: `${aliceId}:0`, }, @@ -735,6 +846,59 @@ describe.each([ }); }); + // Test cases for footer visibility in PIP mode across different platforms + const PIP_FOOTER_VISIBILITY_TEST_CASES: Array<{ + platform: "ios" | "android" | "desktop"; + expectedMarbles: string; + description: string; + }> = [ + { + platform: "ios", + expectedMarbles: "tf", + description: "hidden on iOS", + }, + { + platform: "android", + expectedMarbles: "tf", + description: "hidden on Android", + }, + { + platform: "desktop", + expectedMarbles: "t", + description: "visible on desktop", + }, + ]; + + it.each(PIP_FOOTER_VISIBILITY_TEST_CASES)( + "footer is $description in PIP mode", + ({ platform: testPlatform, expectedMarbles }) => { + withTestScheduler(({ schedule, expectObservable }) => { + // Set platform for this test case + getPlatform.mockReturnValue(testPlatform); + + // Enable PIP mode after initial render + const pipControlInputMarbles = "-e"; + + withCallViewModel( + { + remoteParticipants$: constant([aliceParticipant]), + rtcMembers$: constant([localRtcMember, aliceRtcMember]), + }, + (vm) => { + schedule(pipControlInputMarbles, { + e: () => window.controls.enablePip(), + }); + + expectObservable(vm.showFooter$).toBe(expectedMarbles, { + t: true, + f: false, + }); + }, + ); + }); + }, + ); + test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => { withTestScheduler(({ behavior, schedule, expectObservable }) => { // Switch to spotlight immediately @@ -956,7 +1120,7 @@ describe.each([ grid: [`${localId}:0`], }, b: { - type: "one-on-one", + type: "one-on-one-landscape", pip: `${localId}:0`, spotlight: `${aliceId}:0`, }, @@ -999,7 +1163,7 @@ describe.each([ grid: [`${localId}:0`], }, b: { - type: "one-on-one", + type: "one-on-one-landscape", pip: `${localId}:0`, spotlight: `${aliceId}:0`, }, @@ -1009,7 +1173,7 @@ describe.each([ grid: [`${localId}:0`, `${aliceId}:0`, `${daveId}:0`], }, d: { - type: "one-on-one", + type: "one-on-one-landscape", pip: `${localId}:0`, spotlight: `${daveId}:0`, }, @@ -1227,7 +1391,7 @@ describe.each([ // ringing the entire time (even once timed out) expectObservable(summarizeLayout$(vm.layout$)).toBe("a", { a: { - type: "one-on-one", + type: "one-on-one-landscape", spotlight: `${localId}:0`, pip: `ringing:${aliceUserId}`, }, @@ -1266,12 +1430,12 @@ describe.each([ // ringing the entire time expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", { a: { - type: "one-on-one", + type: "one-on-one-landscape", spotlight: `${localId}:0`, pip: `ringing:${aliceUserId}`, }, b: { - type: "one-on-one", + type: "one-on-one-landscape", spotlight: `${aliceId}:0`, pip: `${localId}:0`, }, diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index e298bcfd..2bbf6f4e 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -15,6 +15,7 @@ import { } from "livekit-client"; import { type Room as MatrixRoom } from "matrix-js-sdk"; import { + BehaviorSubject, catchError, combineLatest, distinctUntilChanged, @@ -68,7 +69,8 @@ import { setPipEnabled$ } from "../../controls"; import { TileStore } from "../TileStore"; import { gridLikeLayout } from "../GridLikeLayout"; import { spotlightExpandedLayout } from "../SpotlightExpandedLayout"; -import { oneOnOneLayout } from "../OneOnOneLayout"; +import { oneOnOneLandscapeLayout } from "../OneOnOneLandscapeLayout"; +import { oneOnOnePortraitLayout } from "../OneOnOnePortraitLayout"; import { pipLayout } from "../PipLayout"; import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement"; import { @@ -86,10 +88,12 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { ElementWidgetActions, widget } from "../../widget"; import { + type Alignment, type GridLayoutMedia, type Layout, type LayoutMedia, - type OneOnOneLayoutMedia, + type OneOnOneLandscapeLayoutMedia, + type OneOnOnePortraitLayoutMedia, type SpotlightExpandedLayoutMedia, type SpotlightLandscapeLayoutMedia, type SpotlightPortraitLayoutMedia, @@ -327,16 +331,6 @@ export interface CallViewModel { { sender: string; emoji: string; startX: number }[] >; - // window/layout - /** - * The general shape of the window. - */ - windowMode$: Behavior; - spotlightExpanded$: Behavior; - toggleSpotlightExpanded$: Behavior<(() => void) | null>; - gridMode$: Behavior; - setGridMode: (value: GridMode) => void; - /** * The layout of tiles in the call interface. */ @@ -347,10 +341,23 @@ export interface CallViewModel { tileStoreGeneration$: Behavior; showSpotlightIndicators$: Behavior; showSpeakingIndicators$: Behavior; + showNameTags$: Behavior; + spotlightExpanded$: Behavior; + toggleSpotlightExpanded$: Behavior<(() => void) | null>; + gridMode$: Behavior; + setGridMode: (value: GridMode) => void; // header/footer visibility showHeader$: Behavior; showFooter$: Behavior; + /** + * Whether the call layout should be displayed edge-to-edge, with the footer + * and header as overlays. + */ + edgeToEdge$: Behavior; + + settingsOpen$: Behavior; + setSettingsOpen$: Behavior<(open: boolean) => void>; // audio routing /** @@ -560,6 +567,7 @@ export function createCallViewModel$( connectionManager, matrixRTCSession, localTransport$, + roomId: matrixRoom.roomId, logger: logger.getChild(`[${Date.now()}]`), }); @@ -776,6 +784,7 @@ export function createCallViewModel$( callPickupState === "timeout" || callPickupState === "decline" ) { + // TODO: Respect io.element.functional_members for (const member of roomMembers.values()) { if (!userMedia.some((vm) => vm.userId === member.userId)) yield { @@ -1056,6 +1065,7 @@ export function createCallViewModel$( [grid$, spotlight$], (grid, spotlight) => ({ type: "grid", + edgeToEdge: false, spotlight: spotlight.some((vm) => vm.type === "screen share") ? spotlight : undefined, @@ -1063,9 +1073,12 @@ export function createCallViewModel$( }), ); - const spotlightLandscapeLayoutMedia$: Observable = + const spotlightLandscapeLayoutMedia$ = ( + edgeToEdge: boolean, + ): Observable => combineLatest([grid$, spotlight$], (grid, spotlight) => ({ type: "spotlight-landscape", + edgeToEdge, spotlight, grid, })); @@ -1073,16 +1086,20 @@ export function createCallViewModel$( const spotlightPortraitLayoutMedia$: Observable = combineLatest([grid$, spotlight$], (grid, spotlight) => ({ type: "spotlight-portrait", + edgeToEdge: false, spotlight, grid, })); - const spotlightExpandedLayoutMedia$: Observable = + const spotlightExpandedLayoutMedia$ = ( + edgeToEdge: boolean, + ): Observable => spotlightAndPip$.pipe( switchMap(({ spotlight, pip$ }) => pip$.pipe( map((pip) => ({ type: "spotlight-expanded" as const, + edgeToEdge, spotlight, pip: pip ?? undefined, })), @@ -1090,55 +1107,88 @@ export function createCallViewModel$( ), ); - const oneOnOneLayoutMedia$: Observable = - combineLatest([userMedia$, screenShares$]).pipe( - switchMap(([userMedia, screenShares]) => { - // One-on-one layout only supports 2 user media, no screen shares - if (userMedia.length <= 2 && screenShares.length === 0) { - const local = userMedia.find( - (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => - vm.type === "user" && vm.local, + const oneOnOneLayoutMedia$: Observable<{ + local: LocalUserMediaViewModel; + remote: UserMediaViewModel | RingingMediaViewModel; + } | null> = combineLatest([userMedia$, screenShares$]).pipe( + switchMap(([userMedia, screenShares]) => { + // One-on-one layout only supports 2 user media, no screen shares + if (userMedia.length <= 2 && screenShares.length === 0) { + const local = userMedia.find( + (vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel => + vm.type === "user" && vm.local, + ); + + if (local !== undefined) { + const remote = userMedia.find( + (vm): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => + vm.type === "user" && !vm.local, ); - if (local !== undefined) { - const remote = userMedia.find( - ( - vm, - ): vm is WrappedUserMediaViewModel & RemoteUserMediaViewModel => - vm.type === "user" && !vm.local, + if (remote !== undefined) return of({ local, remote }); + + // If there's no other user media in the call (could still happen in + // this branch due to the duplicate tiles option), we could possibly + // show ringing media instead + if (userMedia.length === 1) + return ringingMedia$.pipe( + map((ringingMedia) => { + return ringingMedia.length === 1 + ? { + local, + remote: ringingMedia[0], + } + : null; + }), ); - - if (remote !== undefined) - return of({ - type: "one-on-one" as const, - spotlight: remote, - pip: local, - }); - - // If there's no other user media in the call (could still happen in - // this branch due to the duplicate tiles option), we could possibly - // show ringing media instead - if (userMedia.length === 1) - return ringingMedia$.pipe( - map((ringingMedia) => { - return ringingMedia.length === 1 - ? { - type: "one-on-one" as const, - spotlight: local, - pip: ringingMedia[0], - } - : null; - }), - ); - } } + } - return of(null); + return of(null); + }), + ); + + const oneOnOneLandscapeLayoutMedia$: Observable = + oneOnOneLayoutMedia$.pipe( + map((media) => { + if (media === null) return null; + return media.remote.type === "ringing" + ? { + type: "one-on-one-landscape" as const, + edgeToEdge: false, + spotlight: media.local, + pip: media.remote, + } + : { + type: "one-on-one-landscape" as const, + edgeToEdge: false, + spotlight: media.remote, + pip: media.local, + }; + }), + ); + + const oneOnOnePortraitLayoutMedia$: Observable = + oneOnOneLayoutMedia$.pipe( + switchMap((media) => { + if (media === null) return of(null); + return media.local.videoEnabled$.pipe( + map((videoEnabled) => ({ + type: "one-on-one-portrait" as const, + edgeToEdge: true as const, + spotlight: media.remote, + pip: videoEnabled ? media.local : undefined, + })), + ); }), ); const pipLayoutMedia$: Observable = spotlight$.pipe( - map((spotlight) => ({ type: "pip", spotlight })), + map((spotlight) => ({ + type: "pip", + edgeToEdge: platform !== "desktop", + spotlight, + })), ); /** @@ -1153,7 +1203,7 @@ export function createCallViewModel$( switchMap((gridMode) => { switch (gridMode) { case "grid": - return oneOnOneLayoutMedia$.pipe( + return oneOnOneLandscapeLayoutMedia$.pipe( switchMap((oneOnOne) => oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne), ), @@ -1162,15 +1212,15 @@ export function createCallViewModel$( return spotlightExpanded$.pipe( switchMap((expanded) => expanded - ? spotlightExpandedLayoutMedia$ - : spotlightLandscapeLayoutMedia$, + ? spotlightExpandedLayoutMedia$(false) + : spotlightLandscapeLayoutMedia$(false), ), ); } }), ); case "narrow": - return oneOnOneLayoutMedia$.pipe( + return oneOnOnePortraitLayoutMedia$.pipe( switchMap((oneOnOne) => oneOnOne === null ? combineLatest([grid$, spotlight$], (grid, spotlight) => @@ -1179,9 +1229,7 @@ export function createCallViewModel$( ? spotlightPortraitLayoutMedia$ : gridLayoutMedia$, ).pipe(switchAll()) - : // The expanded spotlight layout makes for a better one-on-one - // experience in narrow windows - spotlightExpandedLayoutMedia$, + : of(oneOnOne), ), ); case "flat": @@ -1191,9 +1239,9 @@ export function createCallViewModel$( case "grid": // Yes, grid mode actually gets you a "spotlight" layout in // this window mode. - return spotlightLandscapeLayoutMedia$; + return spotlightLandscapeLayoutMedia$(true); case "spotlight": - return spotlightExpandedLayoutMedia$; + return spotlightExpandedLayoutMedia$(true); } }), ); @@ -1204,6 +1252,201 @@ export function createCallViewModel$( ), ); + const showSpotlightIndicators$ = scope.behavior( + layoutMedia$.pipe(map((l) => l.type !== "grid")), + ); + + const showSpeakingIndicators$ = scope.behavior( + layoutMedia$.pipe( + map((l) => { + switch (l.type) { + case "spotlight-landscape": + case "spotlight-portrait": + // If the spotlight is showing the active speaker, we can do without + // speaking indicators as they're a redundant visual cue. But if + // screen sharing feeds are in the spotlight we still need them. + return l.spotlight.some((m) => m.type === "screen share"); + // In expanded spotlight layout, the active speaker is always shown in + // the picture-in-picture tile so there is no need for speaking + // indicators. And in one-on-one layout there's no question as to who is + // speaking. + case "spotlight-expanded": + case "one-on-one-landscape": + case "one-on-one-portrait": + return false; + default: + return true; + } + }), + ), + ); + + const showNameTags$ = scope.behavior( + layoutMedia$.pipe( + switchMap((l) => + l.type === "pip" || l.type === "one-on-one-portrait" + ? matrixRoomMembers$.pipe( + map( + (members) => + // Hide name tags by default in these layouts. For safety we + // still need to show them in case it wouldn't be clear who + // the spotlight media belongs to. + // TODO: Respect io.element.functional_members (while still + // being careful to never show a functional member's media + // without a name tag!) + // TODO: Only hide name tags in DMs, not group chats that just + // happen to have only 2 users + members.size > 2, + ), + ) + : of(true), + ), + ), + ); + + const toggleSpotlightExpanded$ = scope.behavior<(() => void) | null>( + windowMode$.pipe( + switchMap((mode) => + mode === "normal" + ? layoutMedia$.pipe( + map( + (l) => + l.type === "spotlight-landscape" || + l.type === "spotlight-expanded", + ), + ) + : of(false), + ), + distinctUntilChanged(), + map((enabled) => + enabled ? (): void => spotlightExpandedToggle$.next() : null, + ), + ), + ); + + const edgeToEdge$ = scope.behavior( + layoutMedia$.pipe(map(({ edgeToEdge }) => edgeToEdge)), + ); + + const screenTap$ = new Subject(); + const controlsTap$ = new Subject(); + const screenHover$ = new Subject(); + const screenUnhover$ = new Subject(); + + const naturallyShowFooter$ = scope.behavior( + edgeToEdge$.pipe( + switchMap((edgeToEdge) => { + if (!edgeToEdge) return of(true); + + // Sadly Firefox has some layering glitches that prevent the footer + // from appearing properly. They happen less often if we never hide + // the footer. + if (isFirefox()) return of(true); + + // Layout is edge-to-edge; show/hide the footer in response to interactions + return windowMode$.pipe( + switchMap((mode) => { + if (mode === "pip" && platform !== "desktop") { + // No controls are shown in mobile pip as interactions are disabled + return of(false); + } + const showInitially = mode !== "flat"; + const timeout$ = mode === "flat" ? timer(showFooterMs) : NEVER; + + return merge( + screenTap$.pipe(map(() => "tap screen" as const)), + controlsTap$.pipe(map(() => "tap controls" as const)), + screenHover$.pipe(map(() => "hover" as const)), + ).pipe( + switchScan((state, interaction) => { + switch (interaction) { + case "tap screen": + return state + ? // Toggle visibility on tap + of(false) + : // Hide after a timeout + timeout$.pipe( + map(() => false), + startWith(true), + ); + case "tap controls": + // The user is interacting with things, so reset the timeout + return timeout$.pipe( + map(() => false), + startWith(true), + ); + case "hover": + // Show on hover and hide after a timeout + return race(timeout$, screenUnhover$.pipe(take(1))).pipe( + map(() => false), + startWith(true), + ); + } + }, showInitially), + startWith(showInitially), + ); + }), + ); + }), + ), + ); + + const urlParams = getUrlParams(); + const showFooterUrlParams = !( + urlParams.header === HeaderStyle.None && urlParams.showControls === false + ); + const showFooter$ = scope.behavior( + naturallyShowFooter$.pipe( + map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams), + ), + ); + const settingsOpen$ = new BehaviorSubject(false); + const setSettingsOpen$ = constant((open: boolean) => { + settingsOpen$.next(open); + }); + + const showHeader$ = scope.behavior( + windowMode$.pipe( + switchMap((mode) => { + // In small windows the header would be too obstructive + if (mode === "pip" || mode === "flat") return of(false); + // In edge-to-edge layouts, couple the visibility of the header + // to that of the footer + return edgeToEdge$.pipe( + switchMap((edgeToEdge) => (edgeToEdge ? showFooter$ : of(true))), + ); + }), + ), + ); + + /** + * The alignment of the floating spotlight tile, if present. + */ + const spotlightAlignment$ = new BehaviorSubject({ + inline: "end", + block: "end", + }); + /** + * The size of the small picture-in-picture tile, if present, when in portrait. + */ + const portraitPipSize$ = scope.behavior( + showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))), + ); + /** + * The alignment of the small picture-in-picture tile, if present, when in portrait. + */ + const portraitPipAlignment$ = new BehaviorSubject({ + inline: "end", + block: "end", + }); + /** + * The alignment of the small picture-in-picture tile, if present, when in landscape. + */ + const landscapePipAlignment$ = new BehaviorSubject({ + inline: "end", + block: "start", + }); + // There is a cyclical dependency here: the layout algorithms want to know // which tiles are on screen, but to know which tiles are on screen we have to // first render a layout. To deal with this we assume initially that no tiles @@ -1230,16 +1473,33 @@ export function createCallViewModel$( case "spotlight-portrait": [layout, newTiles] = gridLikeLayout( media, + spotlightAlignment$, visibleTiles, setVisibleTiles, prevTiles, ); break; case "spotlight-expanded": - [layout, newTiles] = spotlightExpandedLayout(media, prevTiles); + [layout, newTiles] = spotlightExpandedLayout( + media, + landscapePipAlignment$, + prevTiles, + ); break; - case "one-on-one": - [layout, newTiles] = oneOnOneLayout(media, prevTiles); + case "one-on-one-landscape": + [layout, newTiles] = oneOnOneLandscapeLayout( + media, + landscapePipAlignment$, + prevTiles, + ); + break; + case "one-on-one-portrait": + [layout, newTiles] = oneOnOnePortraitLayout( + media, + portraitPipSize$, + portraitPipAlignment$, + prevTiles, + ); break; case "pip": [layout, newTiles] = pipLayout(media, prevTiles); @@ -1267,130 +1527,6 @@ export function createCallViewModel$( layoutInternals$.pipe(map(({ tiles }) => tiles.generation)), ); - const showSpotlightIndicators$ = scope.behavior( - layout$.pipe(map((l) => l.type !== "grid")), - ); - - const showSpeakingIndicators$ = scope.behavior( - layout$.pipe( - switchMap((l) => { - switch (l.type) { - case "spotlight-landscape": - case "spotlight-portrait": - // If the spotlight is showing the active speaker, we can do without - // speaking indicators as they're a redundant visual cue. But if - // screen sharing feeds are in the spotlight we still need them. - return l.spotlight.media$.pipe( - map((models: MediaViewModel[]) => - models.some((m) => m.type === "screen share"), - ), - ); - // In expanded spotlight layout, the active speaker is always shown in - // the picture-in-picture tile so there is no need for speaking - // indicators. And in one-on-one layout there's no question as to who is - // speaking. - case "spotlight-expanded": - case "one-on-one": - return of(false); - default: - return of(true); - } - }), - ), - ); - - const toggleSpotlightExpanded$ = scope.behavior<(() => void) | null>( - windowMode$.pipe( - switchMap((mode) => - mode === "normal" - ? layout$.pipe( - map( - (l) => - l.type === "spotlight-landscape" || - l.type === "spotlight-expanded", - ), - ) - : of(false), - ), - distinctUntilChanged(), - map((enabled) => - enabled ? (): void => spotlightExpandedToggle$.next() : null, - ), - ), - ); - - const screenTap$ = new Subject(); - const controlsTap$ = new Subject(); - const screenHover$ = new Subject(); - const screenUnhover$ = new Subject(); - - const showHeader$ = scope.behavior( - windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")), - ); - - const urlParams = getUrlParams(); - const showFooterUrlParams = !( - urlParams.header === HeaderStyle.None && urlParams.showControls === false - ); - const showFooterLayout$ = scope.behavior( - windowMode$.pipe( - switchMap((mode) => { - switch (mode) { - case "pip": - return of(platform === "desktop" ? true : false); - case "normal": - case "narrow": - return of(true); - case "flat": - // Sadly Firefox has some layering glitches that prevent the footer - // from appearing properly. They happen less often if we never hide - // the footer. - if (isFirefox()) return of(true); - // Show/hide the footer in response to interactions - return merge( - screenTap$.pipe(map(() => "tap screen" as const)), - controlsTap$.pipe(map(() => "tap controls" as const)), - screenHover$.pipe(map(() => "hover" as const)), - ).pipe( - switchScan((state, interaction) => { - switch (interaction) { - case "tap screen": - return state - ? // Toggle visibility on tap - of(false) - : // Hide after a timeout - timer(showFooterMs).pipe( - map(() => false), - startWith(true), - ); - case "tap controls": - // The user is interacting with things, so reset the timeout - return timer(showFooterMs).pipe( - map(() => false), - startWith(true), - ); - case "hover": - // Show on hover and hide after a timeout - return race( - timer(showFooterMs), - screenUnhover$.pipe(take(1)), - ).pipe( - map(() => false), - startWith(true), - ); - } - }, false), - startWith(false), - ); - } - }), - ), - ); - const showFooter$ = scope.behavior( - showFooterLayout$.pipe( - map((showFooter) => showFooter && showFooterUrlParams), - ), - ); /** * Whether audio is currently being output through the earpiece. */ @@ -1594,7 +1730,6 @@ export function createCallViewModel$( audibleReactions$: audibleReactions$, visibleReactions$: visibleReactions$, - windowMode$: windowMode$, spotlightExpanded$: spotlightExpanded$, toggleSpotlightExpanded$: toggleSpotlightExpanded$, gridMode$: gridMode$, @@ -1620,8 +1755,12 @@ export function createCallViewModel$( tileStoreGeneration$: tileStoreGeneration$, showSpotlightIndicators$: showSpotlightIndicators$, showSpeakingIndicators$: showSpeakingIndicators$, + showNameTags$, showHeader$: showHeader$, showFooter$: showFooter$, + settingsOpen$: settingsOpen$, + setSettingsOpen$: setSettingsOpen$, + edgeToEdge$, earpieceMode$: earpieceMode$, audioOutputSwitcher$: audioOutputSwitcher$, reconnecting$: localMembership.reconnecting$, diff --git a/src/state/CallViewModel/CallViewModelTestUtils.ts b/src/state/CallViewModel/CallViewModelTestUtils.ts index 9685c709..3155eb11 100644 --- a/src/state/CallViewModel/CallViewModelTestUtils.ts +++ b/src/state/CallViewModel/CallViewModelTestUtils.ts @@ -8,11 +8,11 @@ Please see LICENSE in the repository root for full details. import { ConnectionState, - type LocalParticipant, type Participant, ParticipantEvent, type RemoteParticipant, type Room as LivekitRoom, + type TrackPublication, } from "livekit-client"; import { SyncState } from "matrix-js-sdk/lib/sync"; import { BehaviorSubject, combineLatest, map, of } from "rxjs"; @@ -72,6 +72,7 @@ export interface CallViewModelInputs { roomMembers: RoomMember[]; livekitConnectionState$: Behavior; speaking: Map>; + videoEnabled: Map>; sharingScreen: Map>; mediaDevices: MediaDevices; initialSyncState: SyncState; @@ -98,6 +99,7 @@ export function withCallViewModel(mode: MatrixRTCMode) { ConnectionState.Connected, ), speaking = new Map(), + videoEnabled = new Map(), sharingScreen = new Map(), mediaDevices = mockMediaDevices({}), initialSyncState = SyncState.Syncing, @@ -151,11 +153,19 @@ export function withCallViewModel(mode: MatrixRTCMode) { .mockReturnValue(remoteParticipants$); const mediaSpy = vi .spyOn(ComponentsCore, "observeParticipantMedia") - .mockImplementation((p) => - of({ participant: p } as Partial< - ComponentsCore.ParticipantMedia - > as ComponentsCore.ParticipantMedia), - ); + .mockImplementation((p) => { + return (videoEnabled.get(p) ?? constant(false)).pipe( + map((videoEnabled) => ({ + participant: p, + isMicrophoneEnabled: false, + isCameraEnabled: videoEnabled, + isScreenShareEnabled: false, + cameraTrack: { + isMuted: !videoEnabled, + } as unknown as TrackPublication, + })), + ); + }); const eventsSpy = vi .spyOn(ComponentsCore, "observeParticipantEvents") .mockImplementation((p, ...eventTypes) => { diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts index 3de6a7d5..4b6bde98 100644 --- a/src/state/CallViewModel/localMember/HomeserverConnected.test.ts +++ b/src/state/CallViewModel/localMember/HomeserverConnected.test.ts @@ -98,108 +98,181 @@ describe("createHomeserverConnected$", () => { // LLM generated test cases. They are a bit overkill but I improved the mocking so it is // easy enough to read them so I think they can stay. // Note: gracePeriodMs is set to 0 to avoid debouncing delays in tests - it("is false when sync state is not Syncing", () => { + it("reports syncing reason when sync state is not Syncing", () => { const hsConnected = createHomeserverConnected$(scope, client, session, 0); - expect(hsConnected.combined$.value).toBe(false); + expect(hsConnected.combined$.value).toEqual([false, "sync"]); }); - it("remains false while membership status is not Connected even if sync is Syncing", () => { + it("reports membership reason when sync is Syncing but membership is not Connected", () => { const hsConnected = createHomeserverConnected$(scope, client, session, 0); client.setSyncState(SyncState.Syncing); - expect(hsConnected.combined$.value).toBe(false); // membership still disconnected + expect(hsConnected.combined$.value).toEqual([false, "membership"]); }); - it("is false when membership status transitions to Connected but ProbablyLeft is true", () => { + it("reports probablyLeft reason when membership transitions to Connected but ProbablyLeft is true", () => { const hsConnected = createHomeserverConnected$(scope, client, session, 0); // Make sync loop OK client.setSyncState(SyncState.Syncing); // Indicate probable leave before connection session.setProbablyLeft(true); session.setMembershipStatus(Status.Connected); - expect(hsConnected.combined$.value).toBe(false); + expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]); }); - it("becomes true only when all three conditions are satisfied", () => { + it("becomes null (connected) only when all three conditions are satisfied", () => { const hsConnected = createHomeserverConnected$(scope, client, session, 0); // 1. Sync loop connected client.setSyncState(SyncState.Syncing); - expect(hsConnected.combined$.value).toBe(false); // not yet membership connected + expect(hsConnected.combined$.value).toEqual([false, "membership"]); // not yet membership connected // 2. Membership connected session.setMembershipStatus(Status.Connected); - expect(hsConnected.combined$.value).toBe(true); // probablyLeft is false + expect(hsConnected.combined$.value).toEqual([true, null]); // probablyLeft is false }); - it("drops back to false when sync loop leaves Syncing", () => { + it("returns syncing reason when sync loop leaves Syncing", () => { const hsConnected = createHomeserverConnected$(scope, client, session, 0); // Reach connected state client.setSyncState(SyncState.Syncing); session.setMembershipStatus(Status.Connected); - expect(hsConnected.combined$.value).toBe(true); + expect(hsConnected.combined$.value).toEqual([true, null]); - // Sync loop error => should flip false + // Sync loop error => should report syncing reason client.setSyncState(SyncState.Error); - expect(hsConnected.combined$.value).toBe(false); + expect(hsConnected.combined$.value).toEqual([false, "sync"]); }); - it("drops back to false when membership status becomes disconnected", () => { + it("returns membershipConnected reason when membership status becomes disconnected", () => { const hsConnected = createHomeserverConnected$(scope, client, session, 0); client.setSyncState(SyncState.Syncing); session.setMembershipStatus(Status.Connected); - expect(hsConnected.combined$.value).toBe(true); + expect(hsConnected.combined$.value).toEqual([true, null]); session.setMembershipStatus(Status.Disconnected); - expect(hsConnected.combined$.value).toBe(false); + expect(hsConnected.combined$.value).toEqual([false, "membership"]); }); - it("drops to false when ProbablyLeft is emitted after being true", () => { + it("returns certainlyConnected reason when ProbablyLeft is emitted", () => { const hsConnected = createHomeserverConnected$(scope, client, session, 0); client.setSyncState(SyncState.Syncing); session.setMembershipStatus(Status.Connected); - expect(hsConnected.combined$.value).toBe(true); + expect(hsConnected.combined$.value).toEqual([true, null]); session.setProbablyLeft(true); - expect(hsConnected.combined$.value).toBe(false); + expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]); }); - it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => { + it("recovers to null (connected) if ProbablyLeft becomes false again while other conditions remain true", () => { const hsConnected = createHomeserverConnected$(scope, client, session, 0); client.setSyncState(SyncState.Syncing); session.setMembershipStatus(Status.Connected); - expect(hsConnected.combined$.value).toBe(true); + expect(hsConnected.combined$.value).toEqual([true, null]); session.setProbablyLeft(true); - expect(hsConnected.combined$.value).toBe(false); + expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]); // Simulate clearing the flag (in realistic scenario membership manager would update) session.setProbablyLeft(false); - expect(hsConnected.combined$.value).toBe(true); + expect(hsConnected.combined$.value).toEqual([true, null]); }); it("composite sequence reflects each individual failure reason", () => { const hsConnected = createHomeserverConnected$(scope, client, session, 0); - // Initially false (sync error + disconnected + not probably left) - expect(hsConnected.combined$.value).toBe(false); + // Initially: sync error + membership disconnected β†’ syncing wins (highest priority) + expect(hsConnected.combined$.value).toEqual([false, "sync"]); - // Fix sync only + // Fix sync only β†’ membershipConnected is now the blocker client.setSyncState(SyncState.Syncing); - expect(hsConnected.combined$.value).toBe(false); + expect(hsConnected.combined$.value).toEqual([false, "membership"]); - // Fix membership + // Fix membership β†’ all conditions satisfied session.setMembershipStatus(Status.Connected); - expect(hsConnected.combined$.value).toBe(true); + expect(hsConnected.combined$.value).toEqual([true, null]); - // Introduce probablyLeft -> false + // Introduce probablyLeft β†’ certainlyConnected session.setProbablyLeft(true); - expect(hsConnected.combined$.value).toBe(false); + expect(hsConnected.combined$.value).toEqual([false, "probablyLeft"]); - // Restore notProbablyLeft -> true again + // Restore notProbablyLeft β†’ connected again session.setProbablyLeft(false); - expect(hsConnected.combined$.value).toBe(true); + expect(hsConnected.combined$.value).toEqual([true, null]); - // Drop sync -> false + // Drop sync β†’ syncing reason client.setSyncState(SyncState.Error); - expect(hsConnected.combined$.value).toBe(false); + expect(hsConnected.combined$.value).toEqual([false, "sync"]); + }); +}); + +describe("createHomeserverConnected$ - combined$ reason values", () => { + let scope: ObservableScope; + let client: MockMatrixClient; + let session: MockMatrixRTCSession; + + beforeEach(() => { + scope = new ObservableScope(); + // Start with sync failing and membership disconnected + client = new MockMatrixClient(SyncState.Error); + session = new MockMatrixRTCSession({ + membershipStatus: Status.Disconnected, + probablyLeft: false, + }); + }); + + afterEach(() => { + scope.end(); + }); + + it("is [true, null] when all three conditions are satisfied", () => { + const { combined$ } = createHomeserverConnected$(scope, client, session, 0); + client.setSyncState(SyncState.Syncing); + session.setMembershipStatus(Status.Connected); + expect(combined$.value).toEqual([true, null]); + }); + + it("reports syncing when sync loop is not Syncing", () => { + const { combined$ } = createHomeserverConnected$(scope, client, session, 0); + // client starts with SyncState.Error, membership also disconnected + expect(combined$.value).toEqual([false, "sync"]); + }); + + it("reports membershipConnected when sync is fine but membership is not Connected", () => { + const { combined$ } = createHomeserverConnected$(scope, client, session, 0); + client.setSyncState(SyncState.Syncing); + // session still Status.Disconnected + expect(combined$.value).toEqual([false, "membership"]); + }); + + it("reports certainlyConnected when probablyLeft is true", () => { + const { combined$ } = createHomeserverConnected$(scope, client, session, 0); + client.setSyncState(SyncState.Syncing); + session.setMembershipStatus(Status.Connected); + session.setProbablyLeft(true); + expect(combined$.value).toEqual([false, "probablyLeft"]); + }); + + it("prioritises syncing over membershipConnected when both fail", () => { + const { combined$ } = createHomeserverConnected$(scope, client, session, 0); + // Both sync (Error) and membership (Disconnected) are failing + expect(combined$.value).toEqual([false, "sync"]); + }); + + it("updates reason as conditions change", () => { + const { combined$ } = createHomeserverConnected$(scope, client, session, 0); + // Initially: syncing fails + expect(combined$.value).toEqual([false, "sync"]); + + // Fix sync β†’ membershipConnected is now the blocker + client.setSyncState(SyncState.Syncing); + expect(combined$.value).toEqual([false, "membership"]); + + // Fix membership β†’ probablyLeft makes certainlyConnected fail + session.setProbablyLeft(true); + session.setMembershipStatus(Status.Connected); + expect(combined$.value).toEqual([false, "probablyLeft"]); + + // Clear probablyLeft β†’ all conditions satisfied + session.setProbablyLeft(false); + expect(combined$.value).toEqual([true, null]); }); }); @@ -231,8 +304,8 @@ describe("createHomeserverConnected$ - Grace Period", () => { GRACE_PERIOD, ); expectObservable(hsConnected.combined$).toBe(expectedConnectedMarbles, { - y: true, - n: false, + y: [true, null], + n: [false, "sync"], }); }); } diff --git a/src/state/CallViewModel/localMember/HomeserverConnected.ts b/src/state/CallViewModel/localMember/HomeserverConnected.ts index 65cc24c6..227c21c3 100644 --- a/src/state/CallViewModel/localMember/HomeserverConnected.ts +++ b/src/state/CallViewModel/localMember/HomeserverConnected.ts @@ -22,13 +22,13 @@ import { switchMap, of, delay, + combineLatest, } from "rxjs"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { Config } from "../../../config/Config"; import { type ObservableScope } from "../../ObservableScope"; import { type Behavior } from "../../Behavior"; -import { and$ } from "../../../utils/observable"; import { type NodeStyleEventEmitter } from "../../../utils/test"; /** @@ -36,8 +36,14 @@ import { type NodeStyleEventEmitter } from "../../../utils/test"; */ const logger = rootLogger.getChild("[HomeserverConnected]"); +export type HomeserverDisconnectReason = "sync" | "membership" | "probablyLeft"; + export interface HomeserverConnected { - combined$: Behavior; + /** + * Emits `[true, null]` when the homeserver connection is healthy, or + * `[false, reason]` when one of the three sub-conditions fails. + */ + combined$: Behavior<[boolean, HomeserverDisconnectReason | null]>; rtsSession$: Behavior; } @@ -45,10 +51,11 @@ export interface HomeserverConnected { * Behavior representing whether we consider ourselves connected to the Matrix homeserver * for the purposes of a MatrixRTC session. * - * Becomes FALSE if ANY sub-condition is fulfilled: - * 1. Sync loop is not in SyncState.Syncing (after grace period) - * 2. membershipStatus !== Status.Connected - * 3. probablyLeft === true + * `combined$` emits `null` when all conditions are satisfied, or the first failing + * reason (priority: syncing > membershipConnected > certainlyConnected): + * 1. Sync loop is not in SyncState.Syncing (after grace period) β†’ "sync" + * 2. membershipStatus !== Status.Connected β†’ "membership" + * 3. probablyLeft === true β†’ "probablyLeft" * * @param scope - The observable scope for lifecycle management. * @param client - The Matrix client to monitor sync state. @@ -109,9 +116,22 @@ export function createHomeserverConnected$( ); const combined$ = scope.behavior( - and$(syncing$, membershipConnected$, certainlyConnected$).pipe( - tap((connected) => { - logger.info(`Homeserver connected update: ${connected}`); + combineLatest([syncing$, membershipConnected$, certainlyConnected$]).pipe( + map( + ([syncing, membership, certainly]): [ + boolean, + HomeserverDisconnectReason | null, + ] => { + if (!syncing) return [false, "sync"]; + if (!membership) return [false, "membership"]; + if (!certainly) return [false, "probablyLeft"]; + return [true, null]; + }, + ), + tap(([connected, reason]) => { + logger.info( + `Homeserver connected update: ${connected ? "connected" : reason}`, + ); }), ), ); diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 6eaaa0b0..25b7191e 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -11,13 +11,23 @@ import { type LivekitTransportConfig, type MatrixRTCSession, } from "matrix-js-sdk/lib/matrixrtc"; -import { describe, expect, it, vi } from "vitest"; +import { + describe, + expect, + it, + vi, + beforeAll, + afterAll, + beforeEach, +} from "vitest"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { BehaviorSubject, map, of } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; import { type LocalParticipant, type LocalTrack } from "livekit-client"; +import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics"; import { MatrixRTCMode } from "../../../settings/settings"; +import { type HomeserverDisconnectReason } from "./HomeserverConnected"; import { flushPromises, mockConfig, @@ -215,9 +225,13 @@ describe("LocalMembership", () => { createPublisherFactory: vi.fn(), joinMatrixRTC: async (): Promise => {}, homeserverConnected: { - combined$: constant(true), + combined$: constant<[boolean, HomeserverDisconnectReason | null]>([ + true, + null, + ]), rtsSession$: constant(RTCMemberStatus.Connected), }, + roomId: "!test-room-id:example.org", }; it("throws error on missing RTC config error", () => { @@ -667,4 +681,210 @@ describe("LocalMembership", () => { // expect(publishers[0].stopTracks).toHaveBeenCalled(); }); // TODO add tests for matrix local matrix participation. + + describe("reconnecting analytics", () => { + beforeAll(() => { + mockConfig(); + }); + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + PosthogAnalytics.resetInstance(); + }); + + it("does not fire CallReconnecting for the initial non-connected state at startup", async () => { + const scope = new ObservableScope(); + const trackSpy = vi.spyOn( + PosthogAnalytics.instance.eventCallReconnecting, + "track", + ); + + // Simulate startup where membership isn't established yet + const hsReason$ = new BehaviorSubject< + [boolean, HomeserverDisconnectReason | null] + >([false, "membership"]); + + const connectionManagerData = new ConnectionManagerData(); + connectionManagerData.add(connectionTransportAConnected, []); + + createLocalMembership$({ + scope, + ...defaultCreateLocalMemberValues, + homeserverConnected: { + combined$: hsReason$, + rtsSession$: constant(RTCMemberStatus.Connected), + }, + connectionManager: { + connectionManagerData$: constant(new Epoch(connectionManagerData)), + }, + localTransport$: new BehaviorSubject({ + advertised$: new BehaviorSubject(aTransport), + active$: new BehaviorSubject(aTransportWithSFUConfig), + }), + }); + + await flushPromises(); + + // Membership is established β€” call is now connected + hsReason$.next([true, null]); + + expect(trackSpy).not.toHaveBeenCalled(); + + scope.end(); + }); + + it("fires CallReconnecting with homeserver reason and duration when reconnected", async () => { + const scope = new ObservableScope(); + const trackSpy = vi.spyOn( + PosthogAnalytics.instance.eventCallReconnecting, + "track", + ); + + const hsReason$ = new BehaviorSubject< + [boolean, HomeserverDisconnectReason | null] + >([true, null]); + + const connectionManagerData = new ConnectionManagerData(); + connectionManagerData.add(connectionTransportAConnected, []); + + createLocalMembership$({ + scope, + ...defaultCreateLocalMemberValues, + homeserverConnected: { + combined$: hsReason$, + rtsSession$: constant(RTCMemberStatus.Connected), + }, + connectionManager: { + connectionManagerData$: constant(new Epoch(connectionManagerData)), + }, + localTransport$: new BehaviorSubject({ + advertised$: new BehaviorSubject(aTransport), + active$: new BehaviorSubject(aTransportWithSFUConfig), + }), + }); + + await flushPromises(); + + hsReason$.next([false, "sync"]); + hsReason$.next([true, null]); + + expect(trackSpy).toHaveBeenCalledWith( + defaultCreateLocalMemberValues.roomId, + "sync", + expect.any(Number), + ); + + scope.end(); + }); + + it("reports livekit reason when livekit disconnects then reconnects", async () => { + const scope = new ObservableScope(); + const trackSpy = vi.spyOn( + PosthogAnalytics.instance.eventCallReconnecting, + "track", + ); + + const connectionState$ = new BehaviorSubject( + ConnectionState.LivekitConnected, + ); + const mutableConnection = { + ...connectionTransportAConnected, + state$: connectionState$, + } as unknown as Connection; + + const connectionManagerData = new ConnectionManagerData(); + connectionManagerData.add(mutableConnection, []); + + createLocalMembership$({ + scope, + ...defaultCreateLocalMemberValues, + homeserverConnected: { + combined$: new BehaviorSubject< + [boolean, HomeserverDisconnectReason | null] + >([true, null]), + rtsSession$: constant(RTCMemberStatus.Connected), + }, + connectionManager: { + connectionManagerData$: constant(new Epoch(connectionManagerData)), + }, + localTransport$: new BehaviorSubject({ + advertised$: new BehaviorSubject(aTransport), + active$: new BehaviorSubject(aTransportWithSFUConfig), + }), + }); + + await flushPromises(); + + connectionState$.next(ConnectionState.LivekitDisconnected); + connectionState$.next(ConnectionState.LivekitConnected); + + expect(trackSpy).toHaveBeenCalledWith( + defaultCreateLocalMemberValues.roomId, + "livekit", + expect.any(Number), + ); + + scope.end(); + }); + + it("fires one event per completed reconnection cycle", async () => { + const scope = new ObservableScope(); + const trackSpy = vi.spyOn( + PosthogAnalytics.instance.eventCallReconnecting, + "track", + ); + + const hsReason$ = new BehaviorSubject< + [boolean, HomeserverDisconnectReason | null] + >([true, null]); + + const connectionManagerData = new ConnectionManagerData(); + connectionManagerData.add(connectionTransportAConnected, []); + + createLocalMembership$({ + scope, + ...defaultCreateLocalMemberValues, + homeserverConnected: { + combined$: hsReason$, + rtsSession$: constant(RTCMemberStatus.Connected), + }, + connectionManager: { + connectionManagerData$: constant(new Epoch(connectionManagerData)), + }, + localTransport$: new BehaviorSubject({ + advertised$: new BehaviorSubject(aTransport), + active$: new BehaviorSubject(aTransportWithSFUConfig), + }), + }); + + await flushPromises(); + + hsReason$.next([false, "membership"]); + hsReason$.next([true, null]); + + hsReason$.next([false, "probablyLeft"]); + hsReason$.next([false, "sync"]); + hsReason$.next([false, "membership"]); + hsReason$.next([true, null]); + + expect(trackSpy).toHaveBeenCalledTimes(2); + expect(trackSpy).toHaveBeenNthCalledWith( + 1, + defaultCreateLocalMemberValues.roomId, + "membership", + expect.any(Number), + ); + expect(trackSpy).toHaveBeenNthCalledWith( + 2, + defaultCreateLocalMemberValues.roomId, + "probablyLeft", + expect.any(Number), + ); + + scope.end(); + }); + }); }); diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index a935e0aa..88f3da0a 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -61,7 +61,6 @@ import { type FailedToStartError, } from "../remoteMembers/Connection.ts"; import { type HomeserverConnected } from "./HomeserverConnected.ts"; -import { and$ } from "../../../utils/observable.ts"; import { type LocalTransport } from "./LocalTransport.ts"; import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts"; @@ -129,6 +128,7 @@ interface Props { createPublisherFactory: (connection: Connection) => Publisher; joinMatrixRTC: (transport: LivekitTransportConfig) => void; homeserverConnected: HomeserverConnected; + roomId: string; localTransport$: Behavior; matrixRTCSession: Pick< MatrixRTCSession, @@ -152,6 +152,7 @@ interface Props { * @param props.logger The logger to use. * @param props.muteStates The mute states for video and audio. * @param props.matrixRTCSession The matrix RTC session to join. + * @param props.roomId The room ID used as the call identifier in analytics events. * @returns * - publisher: The handle to create tracks and publish them to the room. * - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication) @@ -169,6 +170,7 @@ export const createLocalMembership$ = ({ logger: parentLogger, muteStates, matrixRTCSession, + roomId: roomId, }: Props): { /** * This request to start audio and video tracks. @@ -494,20 +496,35 @@ export const createLocalMembership$ = ({ ); /** - * Whether we are "fully" connected to the call. Accounts for both the - * connection to the MatrixRTC session and the LiveKit publish connection. + * The disconnect reason for the combined Matrix + LiveKit connection, or null + * when fully connected. Homeserver reasons take priority over livekit. + * Both connectivity state and reason come from the same combineLatest emission, + * avoiding any race between the two. */ - const matrixAndLivekitConnected$ = scope.behavior( - and$( + const connectionDisconnectReason$ = scope.behavior( + combineLatest([ homeserverConnected.combined$, localConnectionState$.pipe( map((state) => state === ConnectionState.LivekitConnected), ), - ).pipe( + ]).pipe( + map(([[hsConnected, hsReason], livekitConnected]) => { + if (!hsConnected) return hsReason!; + if (!livekitConnected) return "livekit" as const; + return null; + }), tap((v) => logger.debug("livekit+matrix: Connected state changed", v)), ), ); + /** + * Whether we are "fully" connected to the call. Accounts for both the + * connection to the MatrixRTC session and the LiveKit publish connection. + */ + const matrixAndLivekitConnected$ = scope.behavior( + connectionDisconnectReason$.pipe(map((reason) => reason === null)), + ); + /** * Whether we should tell the user that we're reconnecting to the call. */ @@ -519,6 +536,33 @@ export const createLocalMembership$ = ({ false, ); + let reconnectStart: { + time: number; + reason: NonNullable<(typeof connectionDisconnectReason$)["value"]>; + } | null = null; + connectionDisconnectReason$ + .pipe(distinctUntilChanged(), pairwise(), scope.bind()) + .subscribe(([prev, reason]) => { + if (reason !== null) { + // Only begin tracking when transitioning FROM connected (null β†’ non-null). + // This prevents the initial startup phase β€” where we may be non-null before + // the first real connection β€” from being counted as a reconnect. + if (prev === null) { + reconnectStart ??= { time: Date.now(), reason }; + } + } else if (reconnectStart !== null) { + PosthogAnalytics.instance.eventCallReconnecting.track( + roomId, + reconnectStart.reason, + (Date.now() - reconnectStart.time) / 1000, + ); + PosthogAnalytics.instance.eventCallEnded.cacheReconnecting( + reconnectStart.reason, + ); + reconnectStart = null; + } + }); + // inform the widget about the connect and disconnect intent from the user. scope .behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [ @@ -606,7 +650,7 @@ export const createLocalMembership$ = ({ // TODO refactor this based no livekitState$ combineLatest([participant$, homeserverConnected.combined$]) .pipe(scope.bind()) - .subscribe(([participant, connected]) => { + .subscribe(([participant, [connected]]) => { if (!participant) return; const publications = participant.trackPublications.values(); if (connected) { diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index b7841c49..a6435212 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -379,10 +379,11 @@ export class Publisher { if (!this.shouldPublish && enable) { await this.pauseUpstreams(lkRoom, [Track.Source.Microphone]); } + return enable; } catch (e) { this.logger.error("Failed to update LiveKit audio input mute state", e); + return lkRoom.localParticipant.isMicrophoneEnabled; } - return lkRoom.localParticipant.isMicrophoneEnabled; }); this.muteStates.video.setHandler(async (enable) => { try { @@ -393,10 +394,11 @@ export class Publisher { if (!this.shouldPublish && enable) { await this.pauseUpstreams(lkRoom, [Track.Source.Camera]); } + return enable; } catch (e) { this.logger.error("Failed to update LiveKit video input mute state", e); + return lkRoom.localParticipant.isCameraEnabled; } - return lkRoom.localParticipant.isCameraEnabled; }); } diff --git a/src/state/GridLikeLayout.ts b/src/state/GridLikeLayout.ts index 0d130834..f91f8e31 100644 --- a/src/state/GridLikeLayout.ts +++ b/src/state/GridLikeLayout.ts @@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type Layout, type LayoutMedia } from "./layout-types.ts"; +import { type BehaviorSubject } from "rxjs"; + +import { + type Alignment, + type Layout, + type LayoutMedia, +} from "./layout-types.ts"; import { type TileStore } from "./TileStore"; export type GridLikeLayoutType = @@ -19,6 +25,7 @@ export type GridLikeLayoutType = */ export function gridLikeLayout( media: LayoutMedia & { type: GridLikeLayoutType }, + spotlightAlignment$: BehaviorSubject, visibleTiles: number, setVisibleTiles: (value: number) => void, prevTiles: TileStore, @@ -37,6 +44,7 @@ export function gridLikeLayout( type: media.type, spotlight: tiles.spotlightTile, grid: tiles.gridTiles, + spotlightAlignment$, setVisibleTiles, } as Layout & { type: GridLikeLayoutType }, tiles, diff --git a/src/state/OneOnOneLayout.ts b/src/state/OneOnOneLandscapeLayout.ts similarity index 55% rename from src/state/OneOnOneLayout.ts rename to src/state/OneOnOneLandscapeLayout.ts index 27fa4439..4198ff03 100644 --- a/src/state/OneOnOneLayout.ts +++ b/src/state/OneOnOneLandscapeLayout.ts @@ -1,29 +1,39 @@ /* Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type OneOnOneLayout, type OneOnOneLayoutMedia } from "./layout-types"; +import { type BehaviorSubject } from "rxjs"; + +import { + type Alignment, + type OneOnOneLandscapeLayout, + type OneOnOneLandscapeLayoutMedia, +} from "./layout-types"; import { type TileStore } from "./TileStore"; /** - * Produces a one-on-one layout with the given media. + * Produces a one-on-one landscape layout with the given media. */ -export function oneOnOneLayout( - media: OneOnOneLayoutMedia, +export function oneOnOneLandscapeLayout( + media: OneOnOneLandscapeLayoutMedia, + pipAlignment$: BehaviorSubject, prevTiles: TileStore, -): [OneOnOneLayout, TileStore] { +): [OneOnOneLandscapeLayout, TileStore] { const update = prevTiles.from(2); update.registerGridTile(media.pip); update.registerGridTile(media.spotlight); const tiles = update.build(); + return [ { type: media.type, spotlight: tiles.gridTilesByMedia.get(media.spotlight)!, pip: tiles.gridTilesByMedia.get(media.pip)!, + pipAlignment$, }, tiles, ]; diff --git a/src/state/OneOnOnePortraitLayout.ts b/src/state/OneOnOnePortraitLayout.ts new file mode 100644 index 00000000..9be80421 --- /dev/null +++ b/src/state/OneOnOnePortraitLayout.ts @@ -0,0 +1,43 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type BehaviorSubject } from "rxjs"; + +import { + type Alignment, + type OneOnOnePortraitLayout, + type OneOnOnePortraitLayoutMedia, +} from "./layout-types"; +import { type TileStore } from "./TileStore"; +import { type Behavior } from "./Behavior"; + +/** + * Produces a one-on-one portrait layout with the given media. + */ +export function oneOnOnePortraitLayout( + media: OneOnOnePortraitLayoutMedia, + pipSize$: Behavior<"sm" | "lg">, + pipAlignment$: BehaviorSubject, + prevTiles: TileStore, +): [OneOnOnePortraitLayout, TileStore] { + const update = prevTiles.from(media.pip === undefined ? 0 : 1); + update.registerSpotlight([media.spotlight], true); + if (media.pip !== undefined) update.registerGridTile(media.pip); + const tiles = update.build(); + + return [ + { + type: media.type, + spotlight: tiles.spotlightTile!, + pip: media.pip && tiles.gridTilesByMedia.get(media.pip), + pipSize$, + pipAlignment$, + }, + tiles, + ]; +} diff --git a/src/state/SpotlightExpandedLayout.ts b/src/state/SpotlightExpandedLayout.ts index 9dc2c815..59ab8ab9 100644 --- a/src/state/SpotlightExpandedLayout.ts +++ b/src/state/SpotlightExpandedLayout.ts @@ -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 { type BehaviorSubject } from "rxjs"; + import { + type Alignment, type SpotlightExpandedLayout, type SpotlightExpandedLayoutMedia, } from "./layout-types"; @@ -16,6 +19,7 @@ import { type TileStore } from "./TileStore"; */ export function spotlightExpandedLayout( media: SpotlightExpandedLayoutMedia, + pipAlignment$: BehaviorSubject, prevTiles: TileStore, ): [SpotlightExpandedLayout, TileStore] { const update = prevTiles.from(1); @@ -27,7 +31,8 @@ export function spotlightExpandedLayout( { type: media.type, spotlight: tiles.spotlightTile!, - pip: tiles.gridTiles[0], + pip: tiles.gridTiles.at(0), + pipAlignment$, }, tiles, ]; diff --git a/src/state/ViewModel.ts b/src/state/ViewModel.ts new file mode 100644 index 00000000..9d245b71 --- /dev/null +++ b/src/state/ViewModel.ts @@ -0,0 +1,49 @@ +/* +Copyright 2026 Element Software Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { BehaviorSubject } from "rxjs"; +import { useState, useEffect } from "react"; + +import { type Behavior } from "./Behavior"; + +export type ViewModel = { + [K in keyof Snapshot as `${string & K}$`]: Behavior; +}; + +/** + * This allows to build a view model (or Partial view model) + * with BehaviorSubjects. + * It can be used in tests and for simplifying view model creation for non reactive snapshot parameters. + * + * @param snapshot The snapshot values this view model with start with. ({a: number, b: string}) + * @returns A view model: ({a$: BehaviroSubject, b$: BehaviroSubject}) (note the automatic addition of $ at the end of the keys) + */ +export function createStaticViewModel( + snapshot: Snapshot, +): ViewModel { + const vm = {} as ViewModel; + for (const key in snapshot) { + (vm as Record>)[`${key}$`] = new BehaviorSubject( + snapshot[key], + ); + } + return vm; +} + +export function useStaticViewModel( + snapshot: Snapshot, +): ViewModel { + const [vm] = useState(() => createStaticViewModel(snapshot)); + useEffect(() => { + for (const key in snapshot) { + (vm as unknown as Record>)[ + `${key}$` + ].next(snapshot[key]); + } + }, [snapshot, vm]); + return vm; +} diff --git a/src/state/layout-types.ts b/src/state/layout-types.ts index 2e779057..2b0d459d 100644 --- a/src/state/layout-types.ts +++ b/src/state/layout-types.ts @@ -5,6 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import { type BehaviorSubject } from "rxjs"; + import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts"; import { type MediaViewModel } from "./media/MediaViewModel.ts"; import { type RingingMediaViewModel } from "./media/RingingMediaViewModel.ts"; @@ -13,39 +15,53 @@ import { type GridTileViewModel, type SpotlightTileViewModel, } from "./TileViewModel.ts"; +import { type Behavior } from "./Behavior.ts"; export interface GridLayoutMedia { type: "grid"; + edgeToEdge: false; spotlight?: MediaViewModel[]; grid: UserMediaViewModel[]; } export interface SpotlightLandscapeLayoutMedia { type: "spotlight-landscape"; + edgeToEdge: boolean; spotlight: MediaViewModel[]; grid: UserMediaViewModel[]; } export interface SpotlightPortraitLayoutMedia { type: "spotlight-portrait"; + edgeToEdge: false; spotlight: MediaViewModel[]; grid: UserMediaViewModel[]; } export interface SpotlightExpandedLayoutMedia { type: "spotlight-expanded"; + edgeToEdge: boolean; spotlight: MediaViewModel[]; pip?: UserMediaViewModel; } -export interface OneOnOneLayoutMedia { - type: "one-on-one"; +export interface OneOnOneLandscapeLayoutMedia { + type: "one-on-one-landscape"; + edgeToEdge: false; spotlight: UserMediaViewModel; pip: LocalUserMediaViewModel | RingingMediaViewModel; } +export interface OneOnOnePortraitLayoutMedia { + type: "one-on-one-portrait"; + edgeToEdge: true; + spotlight: UserMediaViewModel | RingingMediaViewModel; + pip?: LocalUserMediaViewModel; +} + export interface PipLayoutMedia { type: "pip"; + edgeToEdge: boolean; spotlight: MediaViewModel[]; } @@ -54,13 +70,20 @@ export type LayoutMedia = | SpotlightLandscapeLayoutMedia | SpotlightPortraitLayoutMedia | SpotlightExpandedLayoutMedia - | OneOnOneLayoutMedia + | OneOnOneLandscapeLayoutMedia + | OneOnOnePortraitLayoutMedia | PipLayoutMedia; +export interface Alignment { + inline: "start" | "end"; + block: "start" | "end"; +} + export interface GridLayout { type: "grid"; spotlight?: SpotlightTileViewModel; grid: GridTileViewModel[]; + spotlightAlignment$: BehaviorSubject; setVisibleTiles: (value: number) => void; } @@ -82,12 +105,22 @@ export interface SpotlightExpandedLayout { type: "spotlight-expanded"; spotlight: SpotlightTileViewModel; pip?: GridTileViewModel; + pipAlignment$: BehaviorSubject; } -export interface OneOnOneLayout { - type: "one-on-one"; +export interface OneOnOneLandscapeLayout { + type: "one-on-one-landscape"; spotlight: GridTileViewModel; pip: GridTileViewModel; + pipAlignment$: BehaviorSubject; +} + +export interface OneOnOnePortraitLayout { + type: "one-on-one-portrait"; + spotlight: SpotlightTileViewModel; + pip?: GridTileViewModel; + pipSize$: Behavior<"sm" | "lg">; + pipAlignment$: BehaviorSubject; } export interface PipLayout { @@ -104,5 +137,6 @@ export type Layout = | SpotlightLandscapeLayout | SpotlightPortraitLayout | SpotlightExpandedLayout - | OneOnOneLayout + | OneOnOneLandscapeLayout + | OneOnOnePortraitLayout | PipLayout; diff --git a/src/tile/GridTile.module.css b/src/tile/GridTile.module.css index ee605e46..7ffe67d4 100644 --- a/src/tile/GridTile.module.css +++ b/src/tile/GridTile.module.css @@ -72,6 +72,10 @@ borders don't support gradients */ } } +.tile.edgeToEdge { + --media-view-border-radius: 0; +} + .muteIcon[data-muted="true"] { color: var(--cpd-color-icon-secondary); } diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 501f440c..2a169cb0 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -77,6 +77,7 @@ test("GridTile is accessible", async () => { targetWidth={300} targetHeight={200} showSpeakingIndicators + showNameTags focusable /> , @@ -109,6 +110,7 @@ test("GridTile displays ringing media", async () => { targetWidth={300} targetHeight={200} showSpeakingIndicators + showNameTags focusable /> , diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 13cf677f..88754b9d 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -62,6 +62,7 @@ interface TileProps { targetHeight: number; displayName: string; mxcAvatarUrl: string | undefined; + showNameTags: boolean; focusable: boolean; } @@ -398,6 +399,7 @@ interface GridTileProps { className?: string; style?: ComponentProps["style"]; showSpeakingIndicators: boolean; + showNameTags: boolean; focusable: boolean; } @@ -419,9 +421,9 @@ export const GridTile: FC = ({ ); } else if (media.local) { diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index 49199253..240f14d1 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -85,6 +85,7 @@ unconditionally select the container so we can use cqmin units */ calc(var(--media-view-border-radius) - var(--cpd-space-3x)) ); padding: var(--fg-inset); + transition: padding 0.3s; display: grid; grid-template-columns: 1fr auto; grid-template-rows: 1fr auto; @@ -94,6 +95,12 @@ unconditionally select the container so we can use cqmin units */ contain: strict; } +@media (prefers-reduced-motion) { + .fg { + transition: none; + } +} + .nameTag { grid-area: nameTag; place-self: end start; diff --git a/src/tile/MediaView.test.tsx b/src/tile/MediaView.test.tsx index 6ef5eb7e..c7881976 100644 --- a/src/tile/MediaView.test.tsx +++ b/src/tile/MediaView.test.tsx @@ -42,6 +42,7 @@ describe("MediaView", () => { targetHeight: 200, mirror: false, unencryptedWarning: false, + showNameTags: true, video: trackReference, userId: "@alice:example.com", mxcAvatarUrl: undefined, @@ -107,6 +108,16 @@ describe("MediaView", () => { expect(screen.getByRole("img", { name: "Not encrypted" })).toBeTruthy(); }); + test("is shown and accessible even with name tag hidden", async () => { + const { container } = render( + + + , + ); + expect(await axe(container)).toHaveNoViolations(); + screen.getByRole("img", { name: "Not encrypted" }); + }); + test("is not shown", () => { render( diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index eb6cc6b4..6ff97f7a 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -44,6 +44,7 @@ interface Props extends ComponentProps { videoEnabled: boolean; unencryptedWarning: boolean; status?: { text: string; Icon: ComponentType> }; + showNameTags: boolean; nameTagLeadingIcon?: ReactNode; displayName: string; mxcAvatarUrl: string | undefined; @@ -72,6 +73,7 @@ export const MediaView: FC = ({ userId, videoEnabled, unencryptedWarning, + showNameTags, nameTagLeadingIcon, displayName, mxcAvatarUrl, @@ -94,6 +96,23 @@ export const MediaView: FC = ({ const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2); + const warnings = unencryptedWarning && ( + + + + ); + return ( = ({ )*/} -

- {nameTagLeadingIcon} - - {displayName} - - {unencryptedWarning && ( - = 100 ? ( +
+ {nameTagLeadingIcon} + - - - )} -
+ {displayName} + + {warnings} +
+ ) : ( + warnings + )} {primaryButton} diff --git a/src/tile/SpotlightTile.module.css b/src/tile/SpotlightTile.module.css index 037cf10d..54e31106 100644 --- a/src/tile/SpotlightTile.module.css +++ b/src/tile/SpotlightTile.module.css @@ -35,7 +35,9 @@ Please see LICENSE in the repository root for full details. .maximised .item { /* Ensure that foreground elements lie within the safe area */ - --media-view-fg-inset: 10px calc(env(safe-area-inset-right) + 10px) 10px + --media-view-fg-inset: calc(var(--call-view-safe-area-inset-top, 0px) + 10px) + calc(env(safe-area-inset-right) + 10px) + calc(var(--call-view-safe-area-inset-bottom, 0px) + 10px) calc(env(safe-area-inset-left) + 10px); } diff --git a/src/tile/SpotlightTile.test.tsx b/src/tile/SpotlightTile.test.tsx index 533c3b2f..ea987007 100644 --- a/src/tile/SpotlightTile.test.tsx +++ b/src/tile/SpotlightTile.test.tsx @@ -65,6 +65,7 @@ test("SpotlightTile is accessible", async () => { expanded={false} onToggleExpanded={toggleExpanded} showIndicators + showNameTags focusable={true} />, ); @@ -106,6 +107,7 @@ test("Screen share volume UI is shown when screen share has audio", async () => expanded={false} onToggleExpanded={toggleExpanded} showIndicators + showNameTags focusable /> , @@ -135,6 +137,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async () expanded={false} onToggleExpanded={toggleExpanded} showIndicators + showNameTags focusable />, ); @@ -171,6 +174,7 @@ test("SpotlightTile displays ringing media", async () => { expanded={false} onToggleExpanded={toggleExpanded} showIndicators + showNameTags focusable={true} />, ); diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 808773b0..09587497 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -66,6 +66,7 @@ interface SpotlightItemBaseProps { userId: string; displayName: string; mxcAvatarUrl: string | undefined; + showNameTags: boolean; focusable: boolean; "aria-hidden"?: boolean; } @@ -244,6 +245,7 @@ interface SpotlightItemProps { * The height this tile will have once its animations have settled. */ targetHeight: number; + showNameTags: boolean; focusable: boolean; intersectionObserver$: Observable; /** @@ -258,6 +260,7 @@ const SpotlightItem: FC = ({ vm, targetWidth, targetHeight, + showNameTags, focusable, intersectionObserver$, snap, @@ -293,6 +296,7 @@ const SpotlightItem: FC = ({ userId: vm.userId, displayName, mxcAvatarUrl, + showNameTags, focusable, "aria-hidden": ariaHidden, }; @@ -381,6 +385,7 @@ interface Props { targetWidth: number; targetHeight: number; showIndicators: boolean; + showNameTags: boolean; focusable: boolean; className?: string; style?: ComponentProps["style"]; @@ -394,6 +399,7 @@ export const SpotlightTile: FC = ({ targetWidth, targetHeight, showIndicators, + showNameTags, focusable = true, className, style, @@ -504,6 +510,7 @@ export const SpotlightTile: FC = ({ vm={vm} targetWidth={targetWidth} targetHeight={targetHeight} + showNameTags={showNameTags} focusable={focusable} intersectionObserver$={intersectionObserver$} // This is how we get the container to scroll to the right media diff --git a/src/utils/observable.ts b/src/utils/observable.ts index 353dc877..c32254db 100644 --- a/src/utils/observable.ts +++ b/src/utils/observable.ts @@ -116,6 +116,8 @@ export function getValue(state$: Observable): T { /** * Creates an Observable that has a value of true whenever all its inputs are * true. + * + * @public */ export function and$(...inputs: Observable[]): Observable { return combineLatest(inputs, (...flags) => flags.every((flag) => flag)); diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index b5438371..7c670308 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -39,6 +39,9 @@ import { aliceRtcMember, localRtcMember } from "./test-fixtures"; import { type RaisedHandInfo, type ReactionInfo } from "../reactions"; import { constant } from "../state/Behavior"; import { MatrixRTCMode } from "../settings/settings"; +import { createCallFooterViewModel } from "../components/CallFooterViewModel"; +import { type FooterSnapshot } from "../components/CallFooter"; +import { type ViewModel } from "../state/ViewModel"; mockConfig({ livekit: { livekit_service_url: "https://example.com" } }); @@ -136,6 +139,7 @@ export function getBasicCallViewModelEnvironment( callViewModelOptions: Partial = {}, ): { vm: CallViewModel; + footerVm: ViewModel; rtcMemberships$: BehaviorSubject; rtcSession: MockRTCSession; handRaisedSubject$: BehaviorSubject>; @@ -148,12 +152,15 @@ export function getBasicCallViewModelEnvironment( const handRaisedSubject$ = new BehaviorSubject({}); const reactionsSubject$ = new BehaviorSubject({}); + const scope = testScope(); + const muteStates = mockMuteStates(); + const mediaDevices = mediaDevicesOverride ?? mockMediaDevices({}); const vm = createCallViewModel$( - testScope(), + scope, rtcSession.asMockedSession(), matrixRoom, - mediaDevicesOverride ?? mockMediaDevices({}), - mockMuteStates(), + mediaDevices, + muteStates, { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, livekitRoomFactory: (): LivekitRoom => @@ -171,8 +178,16 @@ export function getBasicCallViewModelEnvironment( reactionsSubject$, constant({ processor: undefined, supported: false }), ); + const footerVm = createCallFooterViewModel( + testScope(), + vm, + muteStates, + mediaDevices, + "reactionId", + ); return { vm, + footerVm, rtcMemberships$, rtcSession, handRaisedSubject$: handRaisedSubject$, diff --git a/src/widget.ts b/src/widget.ts index 2ec76e15..462fc6e0 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -61,7 +61,7 @@ export interface WidgetHelpers { * is initialized with `initializeWidget`. This should happen at the top level because the widget messaging * needs to be set up ASAP on load to ensure it doesn't miss any requests. */ -export let widget: WidgetHelpers | null; +export let widget: WidgetHelpers | null = null; /** * Should be called as soon as possible on app start. (In the initilizer before react) diff --git a/vite.config.ts b/vite.config.ts index 801ea79a..59781d3a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,18 +24,10 @@ import react from "@vitejs/plugin-react"; import { realpathSync } from "fs"; import * as fs from "node:fs"; -// https://vitejs.dev/config/ -// Modified type helper from defineConfig to allow for packageType (see defineConfig from vite) -export default ({ +export const vitePluginsConfig = ({ mode, - packageType, -}: ConfigEnv & { packageType?: "full" | "embedded" }): UserConfig => { +}: Pick): UserConfig => { const env = loadEnv(mode, process.cwd()); - // Environment variables with the VITE_ prefix are accessible at runtime. - // So, we set this to allow for build/package specific behavior. - // In future we might be able to do what is needed via code splitting at - // build time. - process.env.VITE_PACKAGE = packageType ?? "full"; const plugins: PluginOption[] = [ react(), wasm(), @@ -72,7 +64,7 @@ export default ({ ); } - if (!process.env.STORYBOOK) { + if (!process.env.STORYBOOK && !process.env.VITEST) { plugins.push( createHtmlPlugin({ entry: "src/main.tsx", @@ -86,6 +78,20 @@ export default ({ ); } + return { plugins }; +}; +// https://vitejs.dev/config/ +// Modified type helper from defineConfig to allow for packageType (see defineConfig from vite) +export default ({ + mode, + packageType, +}: ConfigEnv & { packageType?: "full" | "embedded" }): UserConfig => { + // Environment variables with the VITE_ prefix are accessible at runtime. + // So, we set this to allow for build/package specific behavior. + // In future we might be able to do what is needed via code splitting at + // build time. + process.env.VITE_PACKAGE = packageType ?? "full"; + // The crypto WASM module is imported dynamically. Since it's common // for developers to use a linked copy of matrix-js-sdk or Rust // crypto (which could reside anywhere on their file system), Vite @@ -102,6 +108,7 @@ export default ({ console.log("Allowed vite paths:", allow); return { + ...vitePluginsConfig({ mode }), server: { port: 3000, fs: { allow }, @@ -136,7 +143,6 @@ export default ({ }, }, }, - plugins, resolve: { alias: { // matrix-widget-api has its transpiled lib/index.js as its entry point, diff --git a/vitest.config.ts b/vitest.config.ts index 90082f58..2dc9382c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,20 +1,54 @@ import { defineConfig, mergeConfig } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; +import { vitePluginsConfig } from "./vite.config"; +import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -import viteConfig from "./vite.config"; +const dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig((configEnv) => mergeConfig( - viteConfig(configEnv), + vitePluginsConfig(configEnv), defineConfig({ test: { - environment: "jsdom", - css: { - modules: { - classNameStrategy: "non-scoped", + fileParallelism: true, + projects: [ + { + extends: true, + test: { + name: "unit", + css: { + include: /.+/, + modules: { + classNameStrategy: "non-scoped", + }, + }, + setupFiles: ["src/vitest.setup.ts"], + environment: "jsdom", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + }, }, - }, - setupFiles: ["src/vitest.setup.ts"], - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + { + plugins: [ + storybookTest({ + // The location of your Storybook config, main.js|ts + configDir: "./.storybook", + }), + ...vitePluginsConfig(configEnv).plugins!, + ], + test: { + name: "storybook", + browser: { + enabled: true, + // Make sure to install Playwright + provider: playwright(), + headless: true, + instances: [{ browser: "chromium" }], + }, + }, + }, + ], coverage: { reporter: ["html", "json"], include: ["src/**/*.{ts,tsx,js,jsx}"],