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/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/README.md b/README.md
index 167fe675..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.
@@ -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,12 +216,12 @@ 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`
@@ -227,11 +233,12 @@ including federation:
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.

@@ -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/package.json b/package.json
index 336b3e66..705cdc11 100644
--- a/package.json
+++ b/package.json
@@ -41,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",
@@ -54,7 +54,7 @@
"@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",
@@ -121,10 +121,9 @@
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4",
- "playwright": "^1.59.1",
"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",
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/pnpm-lock.yaml b/pnpm-lock.yaml
index ebb0171a..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
@@ -149,10 +149,10 @@ 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))
@@ -236,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
@@ -245,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
@@ -261,9 +261,6 @@ importers:
pako:
specifier: ^2.0.4
version: 2.1.0
- playwright:
- specifier: ^1.59.1
- version: 1.59.1
postcss:
specifier: ^8.4.41
version: 8.5.11
@@ -271,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
@@ -341,7 +338,7 @@ importers:
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.1.5
- version: 4.1.5(@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))
+ 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)
@@ -373,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':
@@ -514,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'}
@@ -712,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
@@ -910,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
@@ -1587,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:
@@ -1681,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==}
@@ -1892,6 +1967,42 @@ packages:
'@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==}
@@ -2989,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==}
@@ -3031,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'
@@ -3049,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':
@@ -3065,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'
@@ -3094,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':
@@ -3119,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'
@@ -3145,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
@@ -3164,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':
@@ -3180,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
@@ -3745,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==}
@@ -3967,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==}
@@ -5078,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
@@ -5108,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
@@ -5153,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:
@@ -5660,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==}
@@ -5701,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==}
@@ -5723,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'}
@@ -6757,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==}
@@ -6957,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:
@@ -7007,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
@@ -7145,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
@@ -7360,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)
@@ -7575,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
@@ -7585,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)
@@ -7616,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)
@@ -8287,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
@@ -8309,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': {}
@@ -8414,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':
@@ -8551,6 +8766,34 @@ snapshots:
'@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': {}
@@ -9243,7 +9486,7 @@ snapshots:
'@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(@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(@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
@@ -9538,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': {}
@@ -9600,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:
@@ -9624,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:
@@ -9637,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
@@ -9661,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:
@@ -9707,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
@@ -9759,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:
@@ -9785,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': {}
@@ -9799,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
@@ -9815,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)
@@ -9843,7 +10089,7 @@ snapshots:
'@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(@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(@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
@@ -9859,7 +10105,7 @@ snapshots:
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
- vitest: 4.1.5(@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(@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
@@ -9879,7 +10125,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
- vitest: 4.1.5(@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(@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)
@@ -9943,7 +10189,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
- vitest: 4.1.5(@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(@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:
@@ -10135,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
@@ -10478,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):
@@ -10710,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
@@ -10991,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)
@@ -11103,7 +11355,7 @@ snapshots:
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.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
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:
@@ -12051,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
@@ -12082,6 +12334,8 @@ snapshots:
loglevel@1.9.2: {}
+ long@5.3.2: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -12127,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
@@ -12757,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: {}
@@ -12794,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:
@@ -12819,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: {}
@@ -13937,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/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(@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/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(@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))
@@ -13962,6 +14243,7 @@ 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/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)
@@ -13987,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/grid/CallLayout.ts b/src/grid/CallLayout.ts
index 4ce5a7c2..3128087b 100644
--- a/src/grid/CallLayout.ts
+++ b/src/grid/CallLayout.ts
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
-import { type BehaviorSubject, type Observable } from "rxjs";
+import { type Observable } from "rxjs";
import { type ComponentType } from "react";
import { type LayoutProps } from "./Grid";
@@ -16,37 +16,18 @@ export interface Bounds {
height: number;
}
-export interface Alignment {
- inline: "start" | "end";
- block: "start" | "end";
-}
-
-export const defaultSpotlightAlignment: Alignment = {
- inline: "end",
- block: "end",
-};
-export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };
-
export interface CallLayoutInputs {
/**
* The minimum bounds of the layout area.
*/
minBounds$: Observable;
- /**
- * The alignment of the floating spotlight tile, if present.
- */
- spotlightAlignment$: BehaviorSubject;
- /**
- * The alignment of the small picture-in-picture tile, if present.
- */
- pipAlignment$: BehaviorSubject;
}
export interface CallLayoutOutputs {
/**
- * Whether the scrolling layer of the layout should appear on top.
+ * Which layer should appear in the foreground.
*/
- scrollingOnTop: boolean;
+ foreground: "fixed" | "scrolling";
/**
* The visually fixed (non-scrolling) layer of the layout.
*/
diff --git a/src/grid/GridLayout.tsx b/src/grid/GridLayout.tsx
index cf46e8b4..79c2b3a4 100644
--- a/src/grid/GridLayout.tsx
+++ b/src/grid/GridLayout.tsx
@@ -32,9 +32,8 @@ interface GridCSSProperties extends CSSProperties {
*/
export const makeGridLayout: CallLayout = ({
minBounds$,
- spotlightAlignment$,
}) => ({
- scrollingOnTop: false,
+ foreground: "fixed",
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives
@@ -42,7 +41,7 @@ export const makeGridLayout: CallLayout = ({
useUpdateLayout();
const alignment = useObservableEagerState(
useInitial(() =>
- spotlightAlignment$.pipe(
+ model.spotlightAlignment$.pipe(
distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
),
@@ -52,11 +51,11 @@ export const makeGridLayout: CallLayout = ({
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) =>
- spotlightAlignment$.next({
+ model.spotlightAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
- [],
+ [model.spotlightAlignment$],
);
return (
diff --git a/src/grid/OneOnOneLayout.module.css b/src/grid/OneOnOneLandscapeLayout.module.css
similarity index 88%
rename from src/grid/OneOnOneLayout.module.css
rename to src/grid/OneOnOneLandscapeLayout.module.css
index 0ac1b78d..15192fb2 100644
--- a/src/grid/OneOnOneLayout.module.css
+++ b/src/grid/OneOnOneLandscapeLayout.module.css
@@ -22,12 +22,6 @@ Please see LICENSE in the repository root for full details.
inset: 0;
}
-.spotlight {
- position: absolute;
- inline-size: 404px;
- block-size: 233px;
-}
-
.slot[data-block-alignment="start"] {
inset-block-end: unset;
}
diff --git a/src/grid/OneOnOneLayout.tsx b/src/grid/OneOnOneLandscapeLayout.tsx
similarity index 61%
rename from src/grid/OneOnOneLayout.tsx
rename to src/grid/OneOnOneLandscapeLayout.tsx
index fd9c0a65..1e21d112 100644
--- a/src/grid/OneOnOneLayout.tsx
+++ b/src/grid/OneOnOneLandscapeLayout.tsx
@@ -1,5 +1,6 @@
/*
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.
@@ -9,31 +10,35 @@ import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
-import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts";
+import { type OneOnOneLandscapeLayout as OneOnOneLandscapeLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout";
-import styles from "./OneOnOneLayout.module.css";
+import styles from "./OneOnOneLandscapeLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
- * An implementation of the "one-on-one" layout, in which the remote participant
- * is shown at maximum size, overlaid by a small view of the local participant.
+ * An implementation of the "one-on-one" layout for landscape screens, in which
+ * the remote participant is shown at maximum size, overlaid by a small view of
+ * the local participant.
*/
-export const makeOneOnOneLayout: CallLayout = ({
- minBounds$,
- pipAlignment$,
-}) => ({
- scrollingOnTop: false,
+export const makeOneOnOneLandscapeLayout: CallLayout<
+ OneOnOneLandscapeLayoutModel
+> = ({ minBounds$ }) => ({
+ foreground: "fixed",
- fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
+ fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode {
useUpdateLayout();
return ;
},
- scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
+ scrolling: function OneOnOneLandscapeLayoutScrolling({
+ ref,
+ model,
+ Slot,
+ }): ReactNode {
useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds$);
- const pipAlignmentValue = useBehavior(pipAlignment$);
+ const pipAlignment = useBehavior(model.pipAlignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
@@ -41,11 +46,11 @@ export const makeOneOnOneLayout: CallLayout = ({
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
- pipAlignment$.next({
+ model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
- [],
+ [model.pipAlignment$],
);
return (
@@ -61,8 +66,8 @@ export const makeOneOnOneLayout: CallLayout = ({
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
- data-block-alignment={pipAlignmentValue.block}
- data-inline-alignment={pipAlignmentValue.inline}
+ data-block-alignment={pipAlignment.block}
+ data-inline-alignment={pipAlignment.inline}
/>
diff --git a/src/grid/OneOnOnePortraitLayout.module.css b/src/grid/OneOnOnePortraitLayout.module.css
new file mode 100644
index 00000000..999f504d
--- /dev/null
+++ b/src/grid/OneOnOnePortraitLayout.module.css
@@ -0,0 +1,46 @@
+/*
+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.
+*/
+
+.layer {
+ block-size: 100%;
+}
+
+.spotlight {
+ block-size: 100%;
+ inline-size: 100%;
+}
+
+.pip {
+ position: absolute;
+ inset: var(--cpd-space-4x);
+}
+
+.pip[data-size="sm"] {
+ inline-size: 88px;
+ block-size: 132px;
+}
+
+.pip[data-size="lg"] {
+ inline-size: 140px;
+ block-size: 210px;
+}
+
+.pip[data-block-alignment="start"] {
+ inset-block-end: unset;
+}
+
+.pip[data-block-alignment="end"] {
+ inset-block-start: unset;
+}
+
+.pip[data-inline-alignment="start"] {
+ inset-inline-end: unset;
+}
+
+.pip[data-inline-alignment="end"] {
+ inset-inline-start: unset;
+}
diff --git a/src/grid/OneOnOnePortraitLayout.tsx b/src/grid/OneOnOnePortraitLayout.tsx
new file mode 100644
index 00000000..4f7c9f45
--- /dev/null
+++ b/src/grid/OneOnOnePortraitLayout.tsx
@@ -0,0 +1,74 @@
+/*
+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 ReactNode, useCallback } from "react";
+import classNames from "classnames";
+
+import { type OneOnOnePortraitLayout as OneOnOnePortraitLayoutModel } from "../state/layout-types.ts";
+import { type CallLayout } from "./CallLayout";
+import styles from "./OneOnOnePortraitLayout.module.css";
+import { type DragCallback, useUpdateLayout } from "./Grid";
+import { useBehavior } from "../useBehavior";
+
+/**
+ * An implementation of the "one-on-one" layout for portrait screens, in which
+ * the remote participant is shown at maximum size, overlaid by a small view of
+ * the local participant.
+ */
+export const makeOneOnOnePortraitLayout: CallLayout<
+ OneOnOnePortraitLayoutModel
+> = () => ({
+ foreground: "scrolling",
+
+ fixed: function OneOnOnePortraitLayoutFixed({ ref, model, Slot }): ReactNode {
+ useUpdateLayout();
+ return (
+
+
+
+ );
+ },
+
+ scrolling: function OneOnOnePortraitLayoutScrolling({
+ ref,
+ model,
+ Slot,
+ }): ReactNode {
+ useUpdateLayout();
+ const pipSize = useBehavior(model.pipSize$);
+ const pipAlignment = useBehavior(model.pipAlignment$);
+ const onDragLocalTile: DragCallback = useCallback(
+ ({ xRatio, yRatio }) =>
+ model.pipAlignment$.next({
+ block: yRatio < 0.5 ? "start" : "end",
+ inline: xRatio < 0.5 ? "start" : "end",
+ }),
+ [model.pipAlignment$],
+ );
+
+ return (
+
+ {model.pip && (
+
+ )}
+
+ );
+ },
+});
diff --git a/src/grid/SpotlightExpandedLayout.tsx b/src/grid/SpotlightExpandedLayout.tsx
index ac47f0d4..b4fd1d0e 100644
--- a/src/grid/SpotlightExpandedLayout.tsx
+++ b/src/grid/SpotlightExpandedLayout.tsx
@@ -19,8 +19,8 @@ import { useBehavior } from "../useBehavior";
*/
export const makeSpotlightExpandedLayout: CallLayout<
SpotlightExpandedLayoutModel
-> = ({ pipAlignment$ }) => ({
- scrollingOnTop: true,
+> = () => ({
+ foreground: "scrolling",
fixed: function SpotlightExpandedLayoutFixed({
ref,
@@ -46,15 +46,15 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
- const pipAlignmentValue = useBehavior(pipAlignment$);
+ const pipAlignment = useBehavior(model.pipAlignment$);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
- pipAlignment$.next({
+ model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
- [],
+ [model.pipAlignment$],
);
return (
@@ -65,8 +65,8 @@ export const makeSpotlightExpandedLayout: CallLayout<
id={model.pip.id}
model={model.pip}
onDrag={onDragPip}
- data-block-alignment={pipAlignmentValue.block}
- data-inline-alignment={pipAlignmentValue.inline}
+ data-block-alignment={pipAlignment.block}
+ data-inline-alignment={pipAlignment.inline}
/>
)}
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/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.tsx b/src/room/InCallView.tsx
index 3a9e7fea..8b44a437 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";
@@ -50,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";
@@ -90,6 +86,13 @@ 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<
@@ -267,8 +270,9 @@ 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 edgeToEdge = useBehavior(vm.edgeToEdge$);
+ const showNameTags = useBehavior(vm.showNameTags$);
const showHeader = useBehavior(vm.showHeader$);
const settingsOpen = useBehavior(vm.settingsOpen$);
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
@@ -298,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
)
@@ -340,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(
@@ -361,54 +367,55 @@ export const InCallView: FC = ({
);
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
@@ -455,12 +462,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}
/>
) : (
@@ -482,7 +489,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}
@@ -493,19 +501,16 @@ 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 renderContent = (): JSX.Element => {
if (layout.type === "pip") {
@@ -518,6 +523,7 @@ export const InCallView: FC = ({
targetWidth={gridBounds.width}
targetHeight={gridBounds.height}
showIndicators={false}
+ showNameTags={showNameTags}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>
@@ -531,8 +537,18 @@ export const InCallView: FC = ({
className={styles.fixedGrid}
style={{
insetBlockStart:
- headerBounds.height > 0 ? headerBounds.bottom : bounds.top,
- height: gridBounds.height,
+ edgeToEdge || headerBounds.height === 0 ? 0 : headerBounds.bottom,
+ height: edgeToEdge ? "100%" : gridBounds.height,
+ // If edge-to-edge, compute new safe area insets that account for the
+ // header and footer.
+ "--call-view-safe-area-inset-top":
+ edgeToEdge && header && showHeader
+ ? `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
+ : undefined,
+ "--call-view-safe-area-inset-bottom":
+ edgeToEdge && showFooter
+ ? `calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)`
+ : undefined,
}}
model={layout}
Layout={layers.fixed}
@@ -550,19 +566,24 @@ 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(
@@ -579,18 +600,19 @@ export const InCallView: FC = ({
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = footerVm !== null && (
+ //asOverlay={edgeToEdge}
);
const allConnections = useBehavior(vm.allConnections$);
return (
- // The onClick handler here exists to control the visibility of the footer,
+ // The pointer handler here exists to control the visibility of the footer,
// and the footer is also viewable by moving focus into it, so this is fine.
- // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions
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..02a0a351 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,
@@ -133,12 +134,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 +157,8 @@ export type LayoutSummary =
| SpotlightLandscapeLayoutSummary
| SpotlightPortraitLayoutSummary
| SpotlightExpandedLayoutSummary
- | OneOnOneLayoutSummary
+ | OneOnOneLandscapeLayoutSummary
+ | OneOnOnePortraitLayoutSummary
| PipLayoutSummary;
function summarizeLayout$(l$: Observable): Observable {
@@ -187,7 +196,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 +205,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 +428,7 @@ describe.each([
expectedLayoutMarbles,
{
a: {
- type: "one-on-one",
+ type: "one-on-one-landscape",
pip: `${localId}:0`,
spotlight: `${aliceId}:0`,
},
@@ -421,6 +444,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 +678,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 +686,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 +697,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`,
},
@@ -956,7 +1059,7 @@ describe.each([
grid: [`${localId}:0`],
},
b: {
- type: "one-on-one",
+ type: "one-on-one-landscape",
pip: `${localId}:0`,
spotlight: `${aliceId}:0`,
},
@@ -999,7 +1102,7 @@ describe.each([
grid: [`${localId}:0`],
},
b: {
- type: "one-on-one",
+ type: "one-on-one-landscape",
pip: `${localId}:0`,
spotlight: `${aliceId}:0`,
},
@@ -1009,7 +1112,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 +1330,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 +1369,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 bd1ed5f3..ec827ad0 100644
--- a/src/state/CallViewModel/CallViewModel.ts
+++ b/src/state/CallViewModel/CallViewModel.ts
@@ -39,6 +39,7 @@ import {
tap,
throttleTime,
timer,
+ BehaviorSubject,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
@@ -69,7 +70,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 {
@@ -87,10 +89,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,
@@ -328,16 +332,10 @@ 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.
*/
@@ -348,10 +346,20 @@ 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>;
@@ -564,6 +572,7 @@ export function createCallViewModel$(
connectionManager,
matrixRTCSession,
localTransport$,
+ roomId: matrixRoom.roomId,
logger: logger.getChild(`[${Date.now()}]`),
});
@@ -780,6 +789,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 {
@@ -1060,6 +1070,7 @@ export function createCallViewModel$(
[grid$, spotlight$],
(grid, spotlight) => ({
type: "grid",
+ edgeToEdge: false,
spotlight: spotlight.some((vm) => vm.type === "screen share")
? spotlight
: undefined,
@@ -1070,6 +1081,7 @@ export function createCallViewModel$(
const spotlightLandscapeLayoutMedia$: Observable =
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
type: "spotlight-landscape",
+ edgeToEdge: false,
spotlight,
grid,
}));
@@ -1077,16 +1089,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,
})),
@@ -1094,55 +1110,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,
+ })),
);
/**
@@ -1157,7 +1206,7 @@ export function createCallViewModel$(
switchMap((gridMode) => {
switch (gridMode) {
case "grid":
- return oneOnOneLayoutMedia$.pipe(
+ return oneOnOneLandscapeLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
),
@@ -1166,7 +1215,7 @@ export function createCallViewModel$(
return spotlightExpanded$.pipe(
switchMap((expanded) =>
expanded
- ? spotlightExpandedLayoutMedia$
+ ? spotlightExpandedLayoutMedia$(false)
: spotlightLandscapeLayoutMedia$,
),
);
@@ -1174,7 +1223,7 @@ export function createCallViewModel$(
}),
);
case "narrow":
- return oneOnOneLayoutMedia$.pipe(
+ return oneOnOnePortraitLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
@@ -1183,9 +1232,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":
@@ -1197,7 +1244,7 @@ export function createCallViewModel$(
// this window mode.
return spotlightLandscapeLayoutMedia$;
case "spotlight":
- return spotlightExpandedLayoutMedia$;
+ return spotlightExpandedLayoutMedia$(true);
}
}),
);
@@ -1208,6 +1255,197 @@ 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) => {
+ 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
@@ -1234,16 +1472,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);
@@ -1271,138 +1526,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
- );
- // candidat to move into the FooterViewModel
- 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),
- );
- }
- }),
- ),
- );
- // candidat to move into the FooterViewModel
- const showFooter$ = scope.behavior(
- showFooterLayout$.pipe(
- map((showFooter) => showFooter && showFooterUrlParams),
- ),
- );
-
- const settingsOpen$ = new BehaviorSubject(false);
- const setSettingsOpen$ = constant((open: boolean) => {
- settingsOpen$.next(open);
- });
-
/**
* Whether audio is currently being output through the earpiece.
*/
@@ -1606,7 +1729,6 @@ export function createCallViewModel$(
audibleReactions$: audibleReactions$,
visibleReactions$: visibleReactions$,
- windowMode$: windowMode$,
spotlightExpanded$: spotlightExpanded$,
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
gridMode$: gridMode$,
@@ -1632,10 +1754,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/layout-types.ts b/src/state/layout-types.ts
index 2e779057..83a80e9a 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: false;
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/vitest.config.ts b/vitest.config.ts
index 074a57f4..fa1b6c1a 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -26,8 +26,6 @@ export default defineConfig((configEnv) =>
},
setupFiles: ["src/vitest.setup.ts"],
environment: "jsdom",
- // an example of file based convention,
- // you don't have to follow it
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
},
@@ -35,7 +33,7 @@ export default defineConfig((configEnv) =>
plugins: [
storybookTest({
// The location of your Storybook config, main.js|ts
- configDir: path.join(dirname, ".storybook"),
+ configDir: "./.storybook",
}),
...vitePluginsConfig(configEnv).plugins!,
],