Compare commits

...

35 Commits

Author SHA1 Message Date
leo 0e2bd8e4b3 (backend) update a room's access level and configuration from the external API
Update a room's access level and/or configuration using PATCH from the
external API. Log modifications and send to analytics.
2026-09-01 18:11:51 +02:00
Florent Chehab cec2eb5a10 (summary) add hostname to analytics properties
This helps track down what was the source of events.
This can be usefull when checking perf of different workers for instance.
2026-08-25 14:48:06 +02:00
snyk-bot 2858d141f4 ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
Snyk has created this PR to upgrade posthog-js from 1.404.1 to 1.409.5.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-24 16:15:15 +02:00
snyk-bot f10429581b ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
Snyk has created this PR to upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0.

See this package in npm:
@pandacss/preset-panda

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-24 15:58:10 +02:00
snyk-bot b3369cddf7 ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
Snyk has created this PR to upgrade @tanstack/react-query from 5.101.1 to 5.101.4.

See this package in npm:
@tanstack/react-query

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-24 15:50:19 +02:00
snyk-bot cfffadc780 ⬆️(frontend) upgrade i18next-resources-to-backend from 1.2.1 to 1.2.3
Snyk has created this PR to upgrade i18next-resources-to-backend from 1.2.1 to 1.2.3.

See this package in npm:
i18next-resources-to-backend

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-24 15:41:16 +02:00
snyk-bot 4ae31b3297 ⬆️(frontend) upgrade @fontsource-variable/atkinson-hyperlegible-next
Snyk has created this PR to upgrade @fontsource-variable/atkinson-hyperlegible-next from 5.2.6 to 5.3.0.

See this package in npm:
@fontsource-variable/atkinson-hyperlegible-next

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-24 15:20:49 +02:00
lebaudantoine 954991c7c3 📱(frontend) improve feedback screen responsiveness on mobile
Tighten the feedback screen layout on mobile viewports so it fits
comfortably on small phone screens and reads naturally without
horizontal scroll or cramped controls.
2026-08-24 14:51:07 +02:00
lebaudantoine 6e2a7f0ca6 📱(frontend) stack idle modal buttons in a column on mobile
Wrap the buttons in the idle modal so they stack vertically on
mobile viewports.

The horizontal layout was cramped on small screens; a column layout
gives each button a comfortable touch target and reads more
naturally on narrow displays.
2026-08-24 14:51:07 +02:00
lebaudantoine 24404e4ea2 📱(frontend) collapse mobile control bar items on narrow viewports
Apply the same approach as the PiP screen to the mobile control
bar: make sure it never overflows on small phone screens by
collapsing some controls into the overflow menu when there is not
enough horizontal room.
2026-08-24 14:51:07 +02:00
lebaudantoine f18d784615 🔖(minor) bump release to 1.28.0 2026-08-24 11:32:06 +02:00
lebaudantoine 1bfe6b8977 🔇(frontend) suppress leaked WebSocket error events from livekit-client
livekit-client leaks the raw WebSocket error Event as an unhandled
rejection when the signal WebSocket errors after connect. This is
mostly seen on Firefox and coincides with signal reconnects.

The event carries zero diagnostic content — the close reason is
already logged by the SDK — so it only adds noise to error
tracking.

Filter it out from our reporting.

Upstream issue: https://github.com/livekit/client-sdk-js/issues/2062
2026-08-23 18:12:18 +02:00
lebaudantoine 84845709d0 🐛(frontend) make the device-in-use logic race-free
The device-in-use logic could race when multiple detection events
fired close together (e.g. rapid mic/cam toggles or several devices
becoming busy at once), leading to inconsistent store state.

Refactor the flow so all updates go through a serialized path,
removing the race conditions and keeping the in-use state
consistent regardless of event ordering.
2026-08-23 17:40:01 +02:00
lebaudantoine e000377b5c ♻️(frontend) track device-in-use state by id instead of by kind
Refactor the device-in-use handling to key state by device id
instead of only by kind (microphone or camera).

On computers with several devices of the same kind, keying by kind
meant that one device being in use marked the whole kind as busy,
even when the user could still use another device. Tracking per id
lets the UI and logic distinguish between devices correctly.

The store is updated accordingly so that per-device state is stored
and read consistently across the app.
2026-08-23 17:40:01 +02:00
lebaudantoine a9fa3eb4ba 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
Also consider `AbortError` with a message like "Timeout starting
video/audio source" as an "already in use" device error.

This can happen for other reasons in theory, but in practice most
occurrences are caused by another application still holding the
camera or microphone.

Route it through the existing device-in-use handling so users get
the same clear explanation as with the standard error, instead of a
generic failure.
2026-08-23 17:40:01 +02:00
lebaudantoine 2ec3f54532 🐛(frontend) handle Firefox/Windows AbortError on device start
Some versions of Firefox on Windows do not raise the
`NotReadableError` that LiveKit expects when the camera or
microphone fails to start. Instead, they surface an `AbortError`
with a message explaining the browser could not start the video or
microphone input.

This case was observed in PostHog error tracking and was not
handled, so users hit an unhelpful failure state.

Detect that specific `AbortError` and route it through the same
device-in-use / not-readable handling as the standard error, so
users get a clear explanation.
2026-08-23 17:40:01 +02:00
lebaudantoine d723c14d84 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
On Chrome and Windows 10, when the camera or microphone is already
in use by another application, the browser rejects
`getUserMedia`. Without dedicated handling, users just saw their
camera or microphone not turning on, without any explanation.

Detect this specific failure and surface a clear message to the
user, so they understand another application is holding the device
and know what to do about it.
2026-08-23 17:40:01 +02:00
lebaudantoine e5f0b1c202 🐛(frontend) fix joined notification tile no longer rendering properly
The toast is enqueued on `ParticipantConnected`, which livekit-client
fires before the participant's track publications exist. At that
point `getTrackPublication(Camera)` is `undefined`, and the track
ref used by the toast is frozen that way — the toast never
re-subscribes to the participant.

Downstream effects:

* LiveKit's `useIsMuted` initializes to `false` when there is no
  publication, so the tile first paints with
  `data-lk-video-muted="false"`, which the stock CSS maps to
  placeholder `opacity: 0` — a blank box.
* Its effect then subscribes to `mutedObserver`, which defaults to
  `true` for the same situation, triggering a second render and
  only then starting the 200 ms fade-in. That render-effect-render
  transition is the visible lag behind the text.
* Because the track ref never updates, no `VideoTrack` ever mounts.
  When the camera is later subscribed, the placeholder fades out
  again, leaving the tile empty.

Re-derive the track ref inside the toast when the participant's
publications become available, so `useIsMuted` sees the real state
from the start and the tile renders the video (or a stable
placeholder) instead of a blank box.
2026-08-21 22:55:28 +02:00
lebaudantoine 9e0d57a8c6 🐛(frontend) hoist mute confirmation dialog to VideoConference level
`MuteButton` owned the confirmation dialog state locally. On the
participant tile, the button lives inside `FadeOverlay`, which
conditionally mounts its children based on tile mouse activity
(3 s idle timeout). Once the user moved the pointer onto the
react-aria portal, mouse events on the tile stopped, the idle timer
expired, `FadeOverlay` unmounted `MuteButton`, and the dialog was
destroyed with it — hence the "closes randomly" behavior.

Render the dialog once at the VideoConference level via a shared
`MuteAlertDialogProvider`, so its lifetime is independent from any
button that opens it.
2026-08-21 22:55:28 +02:00
lebaudantoine 7ba0803b71 💄(frontend) increase the blur intensity
Bump the blur strength applied to virtual backgrounds so it obscures
the background more effectively.

Requested by users.
2026-08-21 22:55:28 +02:00
lebaudantoine beacfc3d3f 🐛(frontend) stop init_virtual_background from firing on blur updates
A regression introduced with the custom virtual background feature
(16daf7b8, March 26): every blur init on Firefox, and every update
call, was going through `init_virtual_background`, which raised an
error because there is no virtual background to initialize in that
code path.

The existing guard was incorrect. Replace it with an early return,
so `init_virtual_background` is only executed when there is actually
a virtual background to initialize.

This does not fix the other virtual background issues that currently
prevent some users from activating their camera; those will be
addressed separately.
2026-08-21 22:55:28 +02:00
lebaudantoine 52e5d99e83 📈(frontend) track errors when starting or stopping a recording
Send an analytics event whenever starting or stopping a recording
fails, so we can monitor how often it happens.

It also gives support the context needed to understand what went
wrong when inspecting a user's session.
2026-08-21 22:55:28 +02:00
lebaudantoine 15ca2b41b4 🐛(frontend) use state instead of a ref for MoreControls container
After the recent refactoring, `MoreControls` only renders once. On
that first render, the container ref is `null`, and when it later
gets a reference to the div, the ref update does not trigger a
re-render.

As a result, the additional controls were never showing up.

Switch from a ref to a state to track the container element. State
updates do trigger a re-render, so the controls now show as
expected once the container is available.
2026-08-21 22:55:28 +02:00
lebaudantoine e34f3dd219 🐛(frontend) treat client-initiated connect aborts as events
`connect()` calls can be aborted by a `disconnect()` before the
join completes — typically when the user leaves, refreshes,
navigates away early, or when the component remounts. This is
expected behavior, not a failure.

Stop reporting these as errors. Track them as analytics events
instead, so we can still monitor their volume without polluting
error dashboards.

Volume is relatively low, but keeping visibility helps troubleshoot
users who complain about difficulty connecting to a room, and could
also flag a component that is re-rendering and killing the
connection.
2026-08-21 22:55:28 +02:00
lebaudantoine feb573551f 💡(frontend) warn in vite config on MediaPipe asset/track-processor drift
Add a build-time warning in the Vite config that fires when the
MediaPipe assets copied by `vite-plugin-static-copy` fall out of
sync with the version used by `@livekit/track-processors`.

This catches at build time the kind of drift that would otherwise
only surface at runtime as broken virtual backgrounds.
2026-08-21 18:09:31 +02:00
lebaudantoine 1d0a0cc637 ⬆️(mail) upgrade Node image for mails-install to align with the frontend
Node 18 is now legacy, and running `make mails-install` was failing
with a "bad engine" error against the current image.

Bump the Node image used for `mails-install` to a more recent
version, aligned with the one used by the frontend, so the make
target runs cleanly again.

Note: the Alpine variant cannot be used here, as `bash` is required
to build the mail templates.
2026-08-20 16:09:03 +02:00
snyk-bot eae93f771f 🔒️(mail) fix vulnerabilities in src/mail/package.json
Upgrade dependencies to fix the following reported vulnerability:

* https://snyk.io/vuln/SNYK-JS-DEEPMERGETS-18912249
2026-08-20 16:09:03 +02:00
Florent Chehab 45175a2a54 ♻️(summary) uniformized S3 settings case
Quick commit to uniformize the S3 related settings case.
2026-08-20 12:50:26 +02:00
Florent Chehab 8b22059b18 (summary) configurable s3 region
When using other S3 providers than minio,
We neeed to configure the region
2026-08-20 12:41:48 +02:00
lebaudantoine 87dbd8069d ⬆️(frontend) upgrade livekit-client and @livekit/components-react
Bump livekit-client 2.20.0 -> 2.21.0 and
@livekit/components-react 2.9.21 -> 2.9.23. Patch/minor upgrade
with data streams v2, data channel flow control and reliability
fixes, iOS Safari double-playback fix, and a build-time type
resolution fix for `skipLibCheck: false`.

No breaking API changes.
2026-08-20 11:08:27 +02:00
lebaudantoine c7420c59a3 ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
Minor version upgrade with bug fixes and internal improvements. No
breaking changes, API deprecations or required actions on our side.

Highlights:

* Better handling of `sendBeacon` quota rejections so events are no
  longer silently dropped.
* Improved error catching for synchronous throws from a
  monkey-patched `window.fetch`.
* Source map improvements for easier debugging.
2026-08-20 11:08:27 +02:00
lebaudantoine f46babfcbd ⬆️(frontend) upgrade i18next and react-i18next patch versions
Bump i18next from 26.3.1 to 26.3.6 and react-i18next from 17.0.8 to
17.0.10. Both are patch upgrades with bug fixes and no breaking
changes, requiring no code modifications on our side.

i18next:

* 26.3.6: widen the optional TypeScript peer dependency range to
  include v7.
* 26.3.1: fix a regression in type definitions related to
  `keyPrefix` to prevent incorrect type pollution.

react-i18next:

* 17.0.10: improve warning messages for `useTranslation` and
  `Trans` to help debug common issues, especially in monorepos.
  Add a new development-only warning when `useTranslation`
  suspends while loading translations.
* 17.0.9: add TypeScript 7 support and fix related type-checking
  issues with the `<Trans>` component.
2026-08-20 11:08:27 +02:00
lebaudantoine 7c465f2148 ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
Update sqlparse to 0.6.0 to pick up fixes for the following high
severity CVEs, all reported against versions prior to 0.6.0:

* CVE-2026-54284: https://avd.aquasec.com/nvd/cve-2026-54284
* CVE-2026-59893: https://avd.aquasec.com/nvd/cve-2026-59893
* CVE-2026-71491: https://avd.aquasec.com/nvd/cve-2026-71491
2026-08-19 13:30:25 +02:00
lebaudantoine d005f202c6 (backend) accept form-urlencoded on the user token endpoint
Accept `application/x-www-form-urlencoded` requests on the user
token endpoint, in addition to the existing JSON support.

This aligns the endpoint with the OAuth 2.0 specification for token
endpoint requests (RFC 6749, sections 3.2 and 4.4.2), so standard
OAuth 2.0 client libraries can call it without any customization,
while keeping backward compatibility with existing JSON clients.
2026-08-19 13:30:25 +02:00
chaitanyaphatak a82023f8b0 📝(docs) fix minor typos in comments and docstrings 2026-08-17 10:30:16 +02:00
65 changed files with 2134 additions and 519 deletions
+47
View File
@@ -8,12 +8,59 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- ✨(backend) update a room's access level and configuration from the external API
### Changed
- 📱(frontend) collapse mobile control bar items on narrow viewports
- 📱(frontend) stack idle modal buttons in a column on mobile
- 📱(frontend) improve feedback screen responsiveness on mobile
- ⬆️(frontend) upgrade @fontsource-variable/atkinson-hyperlegible-next
- ⬆️(frontend) upgrade i18next-resources-to-backend from 1.2.1 to 1.2.3
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
- ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
## [1.28.0] - 2026-08-24
### Added
- 📈(frontend) track errors when starting or stopping a recording
- 🚸(frontend) explain camera-in-use failures on the join screen
### Changed
- ✨(backend) accept form-urlencoded on the user token endpoint
- ✨(summary) configurable s3 region
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
- 💄(frontend) increase the blur intensity
### Fixed
- 📝(docs) fix minor typos in comments and docstrings
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
- 🐛(frontend) treat client-initiated connect aborts as events
- 🐛(frontend) use state instead of a ref for MoreControls container
- 🐛(frontend) stop init_virtual_background from firing on blur updates
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
- 🐛(frontend) fix joined notification tile no longer rendering properly
- 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
- 🐛(frontend) handle Firefox/Windows AbortError on device start
- 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
- 🔇(frontend) suppress leaked WebSocket error events from livekit-client
## [1.27.0] - 2026-08-14 ## [1.27.0] - 2026-08-14
### Changed ### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver - 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines - 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
- ✨(summary) add hostname to analytics properties
### Fixed ### Fixed
+1 -1
View File
@@ -4,7 +4,7 @@
Security is very important to us. Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr If you have any issue regarding security, please disclose the information responsibly by submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
We appreciate your effort to make Visio more secure. We appreciate your effort to make Visio more secure.
+1 -1
View File
@@ -180,7 +180,7 @@ services:
working_dir: /app working_dir: /app
node: node:
image: node:18 image: node:22
user: "${DOCKER_USER:-1000}" user: "${DOCKER_USER:-1000}"
environment: environment:
HOME: /tmp HOME: /tmp
+105 -3
View File
@@ -16,11 +16,11 @@ info:
* `rooms:list` List rooms accessible to the delegated user. * `rooms:list` List rooms accessible to the delegated user.
* `rooms:retrieve` Retrieve details of a specific room. * `rooms:retrieve` Retrieve details of a specific room.
* `rooms:create` Create new rooms. * `rooms:create` Create new rooms.
* `rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room. * `rooms:update` Update the access level and configuration of existing rooms.
* `rooms:delete` **Coming soon** Delete rooms generated by the application. * `rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features #### Upcoming Features
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically. * **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed. * **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
@@ -50,10 +50,24 @@ paths:
The application must be authorized for the user's email domain. The application must be authorized for the user's email domain.
The returned token expires after a configured duration and must be refreshed by calling this endpoint again. The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
Request parameters may be sent either as "application/x-www-form-urlencoded"
(as specified by RFC 6749 for OAuth 2.0 token endpoints) or as "application/json".
operationId: generateToken operationId: generateToken
requestBody: requestBody:
required: true required: true
content: content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TokenRequest'
examples:
tokenRequest:
summary: Request token for user delegation
value:
client_id: "550e8400-e29b-41d4-a716-446655440000"
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type: "client_credentials"
scope: "user@example.com"
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/TokenRequest' $ref: '#/components/schemas/TokenRequest'
@@ -117,6 +131,19 @@ paths:
summary: Domain not authorized summary: Domain not authorized
value: value:
error: "This application is not authorized for this email domain." error: "This application is not authorized for this email domain."
'415':
description: |
Unsupported media type. The request body must be sent as
"application/x-www-form-urlencoded" or "application/json".
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
unsupportedMediaType:
summary: Unsupported request content type
value:
detail: 'Unsupported media type "text/plain" in request.'
/rooms: /rooms:
get: get:
@@ -283,6 +310,67 @@ paths:
'404': '404':
$ref: '#/components/responses/RoomNotFoundError' $ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only the delegated user's rooms where they are
administrator or owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components: components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
@@ -359,6 +447,17 @@ components:
configuration: configuration:
$ref: '#/components/schemas/RoomConfiguration' $ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration: RoomConfiguration:
type: object type: object
description: | description: |
@@ -400,6 +499,9 @@ components:
- `public`: Anyone with the room link can join directly, no authentication required. - `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval. - `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication. - `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted" example: "trusted"
Room: Room:
+76 -1
View File
@@ -20,7 +20,7 @@ info:
* `lasuite_visio:rooms:list` List rooms accessible to the delegated user. * `lasuite_visio:rooms:list` List rooms accessible to the delegated user.
* `lasuite_visio:rooms:retrieve` Retrieve details of a specific room. * `lasuite_visio:rooms:retrieve` Retrieve details of a specific room.
* `lasuite_visio:rooms:create` Create new rooms. * `lasuite_visio:rooms:create` Create new rooms.
* `lasuite_visio:rooms:update` **Coming soon** Update existing rooms, e.g., add attendees to a room. * `lasuite_visio:rooms:update` Update the access level and configuration of existing rooms.
* `lasuite_visio:rooms:delete` **Coming soon** Delete rooms generated by the application. * `lasuite_visio:rooms:delete` **Coming soon** Delete rooms generated by the application.
#### Upcoming Features #### Upcoming Features
@@ -206,6 +206,67 @@ paths:
'404': '404':
$ref: '#/components/responses/RoomNotFoundError' $ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only rooms where the user is administrator or
owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components: components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
@@ -227,6 +288,17 @@ components:
configuration: configuration:
$ref: '#/components/schemas/RoomConfiguration' $ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration: RoomConfiguration:
type: object type: object
description: | description: |
@@ -268,6 +340,9 @@ components:
- `public`: Anyone with the room link can join directly, no authentication required. - `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval. - `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication. - `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted" example: "trusted"
Room: Room:
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.27.0" version = "1.28.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.6.7", "livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]] [[package]]
name = "agents" name = "agents"
version = "1.27.0" version = "1.28.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "livekit-agents" }, { name = "livekit-agents" },
+1
View File
@@ -8,6 +8,7 @@ class AnalyticsEvent(StrEnum):
# Rooms # Rooms
ROOM_CREATED = "room_created" ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
# Roomkit (meeting-room SIP devices) # Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined" ROOMKIT_JOINED = "roomkit_joined"
+2 -25
View File
@@ -89,11 +89,7 @@ from core.services.participants_management import (
ParticipantsManagementException, ParticipantsManagementException,
) )
from core.services.room_creation import RoomCreation from core.services.room_creation import RoomCreation
from core.services.room_management import ( from core.services.room_management import sync_room_metadata
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.room_roles import ( from core.services.room_roles import (
RoomRoleError, RoomRoleError,
RoomRoleService, RoomRoleService,
@@ -370,26 +366,7 @@ class RoomViewSet(
): ):
return return
metadata = { sync_room_metadata(room)
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@decorators.action( @decorators.action(
detail=True, detail=True,
+66 -20
View File
@@ -12,6 +12,9 @@ from rest_framework import decorators, mixins, viewsets
from rest_framework import ( from rest_framework import (
exceptions as drf_exceptions, exceptions as drf_exceptions,
) )
from rest_framework import (
parsers as drf_parsers,
)
from rest_framework import ( from rest_framework import (
response as drf_response, response as drf_response,
) )
@@ -22,6 +25,7 @@ from rest_framework import (
from core import analytics, api, models from core import analytics, api, models
from core.api.feature_flag import FeatureFlag from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService from core.services.jwt_token import JwtTokenService
from core.services.room_management import sync_room_metadata
from ..services.provisional_user_service import ( from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError, ProvisionalUserCreationDisabledError,
@@ -41,6 +45,7 @@ class ApplicationViewSet(viewsets.ViewSet):
methods=["post"], methods=["post"],
url_path="token", url_path="token",
url_name="token", url_name="token",
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
) )
@FeatureFlag.require("application") @FeatureFlag.require("application")
def generate_jwt_access_token(self, request, *args, **kwargs): def generate_jwt_access_token(self, request, *args, **kwargs):
@@ -138,6 +143,7 @@ class RoomViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
"""Application-delegated API for room management. """Application-delegated API for room management.
@@ -150,8 +156,12 @@ class RoomViewSet(
- list: List rooms the user has access to (requires 'rooms:list' scope) - list: List rooms the user has access to (requires 'rooms:list' scope)
- retrieve: Get room details (requires 'rooms:retrieve' scope) - retrieve: Get room details (requires 'rooms:retrieve' scope)
- create: Create a new room owned by the user (requires 'rooms:create' scope) - create: Create a new room owned by the user (requires 'rooms:create' scope)
- partial_update: Update a room's access level and configuration, for
administrators and owners only (requires 'rooms:update' scope)
""" """
http_method_names = ["get", "post", "patch", "head", "options"]
authentication_classes = [ authentication_classes = [
authentication.ApplicationJWTAuthentication, authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication, authentication.AddonsJWTAuthentication,
@@ -185,6 +195,38 @@ class RoomViewSet(
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data) return drf_response.Response(serializer.data)
def _track_room_event(self, room, event, **extra_properties):
"""Log a room operation for auditing and forward it to analytics."""
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
# Log for auditing
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
logger.info(
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
event.removeprefix("room_"),
room.id,
self.request.user.id,
client_id,
auth_method,
details,
)
analytics.capture(
self.request.user,
event,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
**extra_properties,
"$set": {"email": self.request.user.email},
},
)
def perform_create(self, serializer): def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.""" """Set the current user as owner of the newly created room."""
room = serializer.save() room = serializer.save()
@@ -194,27 +236,31 @@ class RoomViewSet(
role=models.RoleChoices.OWNER, role=models.RoleChoices.OWNER,
) )
auth_method = type(self.request.successful_authenticator).__name__ self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
client_id = (self.request.auth or {}).get("client_id", "unknown")
# Log for auditing def perform_update(self, serializer):
logger.info( """Persist the room update, sync it to LiveKit, then log and track it."""
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
room.id, previous_values = {
self.request.user.id, "access_level": serializer.instance.access_level,
client_id, "configuration": serializer.instance.configuration,
auth_method, }
room = serializer.save()
# Report the fields that actually changed, not the ones that were submitted.
updated_fields = sorted(
field
for field, previous_value in previous_values.items()
if getattr(room, field) != previous_value
) )
analytics.capture( if updated_fields:
self.request.user, sync_room_metadata(room)
analytics.AnalyticsEvent.ROOM_CREATED,
{ self._track_room_event(
"room_id": str(room.pk), room,
"access_level": room.access_level, analytics.AnalyticsEvent.ROOM_UPDATED,
"client_id": client_id, updated_fields=updated_fields,
"external_api": True, previous_access_level=previous_values["access_level"],
"auth_method": auth_method,
"$set": {"email": self.request.user.email},
},
) )
+1 -1
View File
@@ -48,7 +48,7 @@ class ResourceFactory(factory.django.DjangoModelFactory):
else: else:
UserResourceAccessFactory(resource=self, user=item[0], role=item[1]) UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
self.save() self.save()
class UserResourceAccessFactory(factory.django.DjangoModelFactory): class UserResourceAccessFactory(factory.django.DjangoModelFactory):
@@ -116,3 +116,32 @@ class RoomManagement:
raise RoomManagementException("Could not delete room") from e raise RoomManagementException("Could not delete room") from e
finally: finally:
await lkapi.aclose() await lkapi.aclose()
def sync_room_metadata(room):
"""Push a room's configuration and access level to its LiveKit room metadata.
Failures are swallowed: a room that is not live yet, or a LiveKit hiccup,
should never fail the request that triggered the update.
"""
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@@ -381,38 +381,11 @@ def test_api_rooms_update_administrators_of_another():
assert other_room.slug == "old-name" assert other_room.slug == "old-name"
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException) @pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException])
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata): @patch.object(RoomManagement, "update_metadata")
"""Should not fail the API request when the LiveKit room does not exist yet.""" def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
"""Should not fail the API request when the LiveKit metadata sync fails.""" """Should not fail the API request when the LiveKit metadata sync fails."""
mock_update_metadata.side_effect = exception
user = UserFactory() user = UserFactory()
room = RoomFactory( room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))], users=[(user, random.choice(["administrator", "owner"]))],
@@ -5,10 +5,13 @@ from unittest import mock
import pytest import pytest
from livekit.api import TwirpError from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.room_management import ( from core.services.room_management import (
RoomManagement, RoomManagement,
RoomManagementException, RoomManagementException,
RoomNotFoundException, RoomNotFoundException,
sync_room_metadata,
) )
@@ -58,3 +61,22 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
RoomManagement().delete_room("room-abc") RoomManagement().delete_room("room-abc")
mock_api.aclose.assert_awaited_once() mock_api.aclose.assert_awaited_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
"""The room's configuration and access level are forwarded to LiveKit."""
room = RoomFactory.build(
access_level=RoomAccessLevel.RESTRICTED,
configuration={"everyone_can_mute": True},
)
sync_room_metadata(room)
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": True},
"access_level": RoomAccessLevel.RESTRICTED,
},
)
@@ -16,8 +16,17 @@ import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core.analytics import AnalyticsEvent
from core.factories import ApplicationFactory, RoomFactory, UserFactory from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User from core.models import (
Application,
ApplicationScope,
RoleChoices,
Room,
RoomAccessLevel,
User,
)
from core.services.room_management import RoomManagement
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -880,6 +889,509 @@ def test_api_rooms_create_public_access_level_when_default_is_public(settings):
assert response.data["access_level"] == RoomAccessLevel.PUBLIC assert response.data["access_level"] == RoomAccessLevel.PUBLIC
@mock.patch("core.external_api.viewsets.analytics.capture")
def test_api_rooms_create_tracks_analytics(mock_capture):
"""Creating a room should emit a ROOM_CREATED analytics event."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 201
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_CREATED
assert properties == {
"room_id": response.data["id"],
"access_level": RoomAccessLevel.RESTRICTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
def test_api_rooms_update_requires_authentication():
"""Updating a room without authentication should return 401."""
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
client = APIClient()
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 401
def test_api_rooms_update_requires_scope():
"""Updating a room requires the ROOMS_UPDATE scope."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_UPDATE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:update"
in str(response.data).lower()
)
def test_api_rooms_update_no_scope():
"""Updating a room without any scope should return 403."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_owner_success(mock_update_metadata, settings):
"""An owner should be able to update the access level and the configuration."""
settings.APPLICATION_BASE_URL = "http://your-application.com"
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
assert response.data["id"] == str(room.id)
assert response.data["access_level"] == RoomAccessLevel.RESTRICTED
assert response.data["configuration"] == {"everyone_can_mute": True}
assert response.data["url"] == f"http://your-application.com/{room.slug}"
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
assert room.configuration == {"everyone_can_mute": True}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": True},
"access_level": RoomAccessLevel.RESTRICTED,
},
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_replaces_configuration(mock_update_metadata):
"""The configuration is replaced as a whole, it is not merged with the stored one."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
configuration={"can_publish_sources": ["camera"], "everyone_can_mute": True},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": {"everyone_can_mute": False}},
format="json",
)
assert response.status_code == 200
# The keys missing from the payload are dropped, not kept.
assert response.data["configuration"] == {"everyone_can_mute": False}
room.refresh_from_db()
assert room.configuration == {"everyone_can_mute": False}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": False},
"access_level": room.access_level,
},
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrator_success(mock_update_metadata):
"""An administrator should be able to update a room."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.ADMIN)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_put_not_allowed(mock_update_metadata):
"""PUT is not exposed: full replacement is not supported, only PATCH is."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.put(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 405
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize("role", [RoleChoices.MEMBER, None])
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_without_privileges(mock_update_metadata, role):
"""Members and users without any role should not be able to update a room."""
user = UserFactory()
users = [(user, role)] if role else []
room = RoomFactory(users=users, access_level=RoomAccessLevel.TRUSTED)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_readonly_enforcement(mock_update_metadata):
"""Read-only fields provided on update should be ignored, the slug stays immutable."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
expected_id, expected_name = str(room.id), room.name
expected_slug, expected_pin_code = room.slug, room.pin_code
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"id": str(uuid.uuid4()),
"name": "fake-name",
"slug": "fake-slug",
"pin_code": "000000",
"access_level": RoomAccessLevel.RESTRICTED,
},
format="json",
)
assert response.status_code == 200
assert response.data["id"] == expected_id
assert response.data["name"] == expected_name
assert response.data["slug"] == expected_slug
room.refresh_from_db()
assert str(room.id) == expected_id
assert room.name == expected_name
assert room.slug == expected_slug
assert room.pin_code == expected_pin_code
# The one writable field in the payload was applied
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_rejects_invalid_configuration(mock_update_metadata):
"""Updating a room with unsupported configuration keys should fail."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": {"unsupported_flag": True}},
format="json",
)
assert response.status_code == 400
assert "extra inputs are not permitted" in str(response.data).lower()
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
"invalid_configuration",
[
{"can_publish_sources": ["invalid-source"]},
{"everyone_can_mute": "invalid-value"},
],
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_rejects_invalid_configuration_values(
mock_update_metadata, invalid_configuration
):
"""Updating a room with invalid configuration values should fail."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": invalid_configuration},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_public_access_disabled_by_default(mock_update_metadata):
"""Switching a room to public should be disabled for the external API by default."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 400
assert "public rooms are disabled" in str(response.data).lower()
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_public_access_enabled_with_settings(
mock_update_metadata, settings
):
"""Switching a room to public should be allowed when explicitly enabled."""
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 200
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.PUBLIC
mock_update_metadata.assert_called_once()
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_unchanged_skips_livekit_sync(
mock_update_metadata, mock_capture
):
"""An update that changes nothing should not sync metadata nor report changes."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={"everyone_can_mute": True},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.TRUSTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
mock_update_metadata.assert_not_called()
# The event is still emitted for auditing, but reports an empty delta.
_, _, properties = mock_capture.call_args[0]
assert properties["updated_fields"] == []
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
"""Updating a room should emit a ROOM_UPDATED analytics event."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_UPDATED
assert properties == {
"room_id": str(room.pk),
"access_level": RoomAccessLevel.RESTRICTED,
"updated_fields": ["access_level", "configuration"],
"previous_access_level": RoomAccessLevel.TRUSTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
mock_update_metadata.assert_called_once()
def test_api_rooms_response_no_url(settings): def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None.""" """Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_BASE_URL = None settings.APPLICATION_BASE_URL = None
@@ -1497,6 +2009,106 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings):
assert response.status_code == 403 assert response.status_code == 403
@responses.activate
@mock.patch.object(RoomManagement, "update_metadata")
def test_resource_server_updates_room_with_prefixed_scope(
mock_update_metadata, settings
):
"""A resource server token carrying the prefixed update scope should be accepted."""
user = UserFactory(sub="very-specific-sub")
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:update",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@responses.activate
def test_resource_server_denies_room_update_without_update_scope(settings):
"""A resource server token without the update scope should be denied."""
user = UserFactory(sub="very-specific-sub")
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:retrieve",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
# ============================== # ==============================
# Addons # Addons
# ============================== # ==============================
@@ -1548,6 +2160,32 @@ def test_api_rooms_create_with_valid_addons_token():
assert room.get_role(user) == RoleChoices.OWNER assert room.get_role(user) == RoleChoices.OWNER
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_with_valid_addons_token(mock_update_metadata):
"""Updating a room with a valid addons token should succeed."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
def test_api_rooms_addons_token_inactive_user(): def test_api_rooms_addons_token_inactive_user():
"""Addons token for an inactive user should return 401.""" """Addons token for an inactive user should return 401."""
user = UserFactory(is_active=False) user = UserFactory(is_active=False)
@@ -5,6 +5,7 @@ Tests for external API /token endpoint
# pylint: disable=W0621 # pylint: disable=W0621
from unittest import mock from unittest import mock
from urllib.parse import urlencode
import jwt import jwt
import pytest import pytest
@@ -88,6 +89,155 @@ def test_api_applications_generate_token_success(settings):
} }
def test_api_applications_generate_token_form_urlencoded(settings):
"""The token endpoint should accept "application/x-www-form-urlencoded"
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
token endpoints, so that standard OAuth 2.0 client libraries work
out of the box."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
(
f"client_id={application.client_id}"
f"&client_secret={plain_secret}"
"&grant_type=client_credentials"
"&scope=user%40example.com"
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
response.data.pop("access_token")
assert response.data == {
"token_type": "Bearer",
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": "rooms:list rooms:create",
}
def test_api_applications_generate_token_form_urlencoded_invalid_credentials():
"""Invalid credentials sent as form-urlencoded should be parsed and
rejected with 401, proving the request body is properly decoded."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_form_urlencoded_missing_fields():
"""Missing required fields in a form-urlencoded request should return
a 400 validation error, like for JSON requests."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode({"grant_type": "client_credentials"}),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
for field in ("client_id", "client_secret", "scope"):
assert field in response.data
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
"""An unsupported grant_type sent as form-urlencoded should return 400."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "authorization_code",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
assert "grant_type" in response.data
def test_api_applications_generate_token_form_urlencoded_special_characters():
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
client_secret should survive form-urlencoded decoding."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
plain_secret = "s3cr3t&with=special+chars%42"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user@example.com",
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
def test_api_applications_generate_token_unsupported_media_type():
"""Content types other than JSON and form-urlencoded should still be
rejected with 415 Unsupported Media Type."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
"client_id=x&client_secret=y&grant_type=client_credentials&scope=a@b.co",
content_type="text/plain",
)
assert response.status_code == 415
def test_api_applications_generate_token_invalid_client_id(): def test_api_applications_generate_token_invalid_client_id():
"""Invalid client_id should return 401.""" """Invalid client_id should return 401."""
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.27.0" version = "1.28.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
+4 -4
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.27.0" version = "1.28.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
@@ -2252,11 +2252,11 @@ wheels = [
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.5.5" version = "0.6.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
] ]
[[package]] [[package]]
+107 -92
View File
@@ -1,40 +1,40 @@
{ {
"name": "meet", "name": "meet",
"version": "1.27.0", "version": "1.28.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.27.0", "version": "1.28.0",
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6", "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.2.11", "@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5", "@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4", "@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.21", "@livekit/components-react": "2.9.23",
"@livekit/components-styles": "1.2.0", "@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2", "@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14", "@mediapipe/tasks-vision": "0.10.14",
"@pandacss/preset-panda": "1.11.3", "@pandacss/preset-panda": "1.12.0",
"@react-types/overlays": "3.10.0", "@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.1", "@tanstack/react-query": "5.101.4",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "26.3.1", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.20.0", "livekit-client": "2.21.0",
"posthog-js": "1.395.0", "posthog-js": "1.409.5",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.8", "react-i18next": "17.0.10",
"react-stately": "3.48.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
@@ -742,34 +742,34 @@
} }
}, },
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.7.5", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.11" "@floating-ui/utils": "^0.2.12"
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.7.4", "version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.3", "@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.10" "@floating-ui/utils": "^0.2.11"
} }
}, },
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.11", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": { "node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
"version": "5.2.6", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.2.6.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.3.0.tgz",
"integrity": "sha512-D0Z8peFkRGYACvmWp8Sl8YPGYLBEJbzqapugtsMEYSUUtpQDiZVDHjk9s7bqB4hSCRYCrKuW6V1V+mnHybVnPw==", "integrity": "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==",
"license": "OFL-1.1", "license": "OFL-1.1",
"funding": { "funding": {
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
@@ -966,12 +966,12 @@
"license": "GPL-3.0+" "license": "GPL-3.0+"
}, },
"node_modules/@livekit/components-core": { "node_modules/@livekit/components-core": {
"version": "0.12.13", "version": "0.12.14",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz", "resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==", "integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@floating-ui/dom": "1.7.4", "@floating-ui/dom": "1.7.6",
"loglevel": "1.9.1", "loglevel": "1.9.1",
"rxjs": "7.8.2" "rxjs": "7.8.2"
}, },
@@ -979,17 +979,17 @@
"node": ">=18" "node": ">=18"
}, },
"peerDependencies": { "peerDependencies": {
"livekit-client": "^2.17.2", "livekit-client": "^2.20.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
} }
}, },
"node_modules/@livekit/components-react": { "node_modules/@livekit/components-react": {
"version": "2.9.21", "version": "2.9.23",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz", "resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==", "integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@livekit/components-core": "0.12.13", "@livekit/components-core": "0.12.14",
"clsx": "2.1.1", "clsx": "2.1.1",
"events": "^3.3.0", "events": "^3.3.0",
"jose": "^6.0.12", "jose": "^6.0.12",
@@ -999,8 +999,8 @@
"node": ">=18" "node": ">=18"
}, },
"peerDependencies": { "peerDependencies": {
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0", "@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0",
"livekit-client": "^2.18.2", "livekit-client": "^2.20.1",
"react": ">=18", "react": ">=18",
"react-dom": ">=18", "react-dom": ">=18",
"tslib": "^2.6.2" "tslib": "^2.6.2"
@@ -1027,9 +1027,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@livekit/protocol": { "node_modules/@livekit/protocol": {
"version": "1.46.6", "version": "1.50.4",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz", "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==", "integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^1.10.0" "@bufbuild/protobuf": "^1.10.0"
@@ -1642,21 +1642,21 @@
} }
}, },
"node_modules/@pandacss/preset-panda": { "node_modules/@pandacss/preset-panda": {
"version": "1.11.3", "version": "1.12.0",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.12.0.tgz",
"integrity": "sha512-IHeRQyIYeGKpQl78A/oI9fGg8MHQiOX/HttH+iQNaRU/MbGN6JsPe/N0ekQC6QY8iiKtLhqR+SRc455SN6JVWw==", "integrity": "sha512-jhzhAmtGY34b1FlMJGEAh/IkfU4WyTNT2mpJfcBUPBi1rZLHSTcs0aqedRmMdwM4tTT43XFOXMiazbztKME60A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@pandacss/types": "1.11.3" "@pandacss/types": "1.12.0"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": { "node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": {
"version": "1.11.3", "version": "1.12.0",
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.12.0.tgz",
"integrity": "sha512-tUbxa3WzoCuulP6kcMn7f0uNgLBvu81JAg4sfoUpz6PZE36VHA3k9n6ROEwziTbr7vqgeCj5DYQCUldrVNbEGQ==", "integrity": "sha512-PtlEIOBEV3RzWffeu1zH+/YFmP+fn24M63STGEbgTwjEBMhyo/FfT2sfZGB8oWAbWUBKBp69OMg25E+/rY3klQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -1719,19 +1719,29 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@posthog/core": { "node_modules/@posthog/browser-common": {
"version": "1.39.5", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz", "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.3.1.tgz",
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==", "integrity": "sha512-1nhMVY1wnHADTg8tR9yvm+lPAz5ROxznfQlBtLzB2FFo4lp/LU8lk9KyFPsARDkBxCfYachsJfvRjocL1G/AJQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@posthog/types": "^1.392.0" "@posthog/core": "^1.46.0",
"@posthog/types": "^1.399.0"
}
},
"node_modules/@posthog/core": {
"version": "1.48.3",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
"license": "MIT",
"dependencies": {
"@posthog/types": "^1.405.0"
} }
}, },
"node_modules/@posthog/types": { "node_modules/@posthog/types": {
"version": "1.392.0", "version": "1.405.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz", "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==", "integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-aria/overlays": { "node_modules/@react-aria/overlays": {
@@ -2414,9 +2424,9 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.101.1", "version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==", "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -2435,12 +2445,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.101.1", "version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==", "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.101.1" "@tanstack/query-core": "5.101.4"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -4393,11 +4403,14 @@
} }
}, },
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.39.0", "version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"engines": {
"node": "*"
},
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
@@ -6400,9 +6413,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.1", "version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -6419,7 +6432,7 @@
], ],
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"typescript": "^5 || ^6" "typescript": "^5 || ^6 || ^7"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"typescript": { "typescript": {
@@ -6972,9 +6985,10 @@
} }
}, },
"node_modules/i18next-resources-to-backend": { "node_modules/i18next-resources-to-backend": {
"version": "1.2.1", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz", "resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.3.tgz",
"integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==", "integrity": "sha512-8Y/LLAm5fqZc2ckQxtTWbu75ndjNLzF7mcYl6rhc4g+WBopIsG3YhdYqF5qhJy64tlYhlU2KSSnEgW3gGHLqwg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.2" "@babel/runtime": "^7.23.2"
} }
@@ -8109,20 +8123,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/livekit-client": { "node_modules/livekit-client": {
"version": "2.20.0", "version": "2.21.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz", "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==", "integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@livekit/mutex": "1.1.1", "@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.46.6", "@livekit/protocol": "1.50.4",
"events": "^3.3.0", "events": "^3.3.0",
"jose": "^6.1.0", "jose": "^6.1.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"sdp-transform": "^2.15.0", "sdp-transform": "^2.15.0",
"tslib": "2.8.1", "tslib": "2.8.1",
"typed-emitter": "^2.1.0", "typed-emitter": "^2.1.0",
"webrtc-adapter": "9.0.5" "webrtc-adapter": "9.0.6"
}, },
"peerDependencies": { "peerDependencies": {
"@types/dom-mediacapture-record": "^1" "@types/dom-mediacapture-record": "^1"
@@ -9120,17 +9134,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/posthog-js": { "node_modules/posthog-js": {
"version": "1.395.0", "version": "1.409.5",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.409.5.tgz",
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==", "integrity": "sha512-s1iJz+vq0YAluUD4OwLjUFIJt/9pqiiB6MITvHWIS16a7pqTJqbSLXsd5/8kJcIgfrOSZHe+mdYAXLYcJCS4LQ==",
"license": "SEE LICENSE IN LICENSE", "license": "(Apache-2.0 AND MIT)",
"dependencies": { "dependencies": {
"@posthog/core": "^1.38.0", "@posthog/browser-common": "^0.3.1",
"@posthog/types": "^1.391.1", "@posthog/core": "^1.46.1",
"core-js": "^3.38.1", "@posthog/types": "^1.399.0",
"core-js": "^3.49.0",
"dompurify": "^3.3.2", "dompurify": "^3.3.2",
"fflate": "^0.4.8", "fflate": "^0.4.8",
"preact": "^10.29.2", "preact": "^10.29.3",
"query-selector-shadow-dom": "^1.0.1", "query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.3.0" "web-vitals": "^5.3.0"
} }
@@ -9422,9 +9437,9 @@
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "17.0.8", "version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.2", "@babel/runtime": "^7.29.2",
@@ -9434,7 +9449,7 @@
"peerDependencies": { "peerDependencies": {
"i18next": ">= 26.2.0", "i18next": ">= 26.2.0",
"react": ">= 16.8.0", "react": ">= 16.8.0",
"typescript": "^5 || ^6" "typescript": "^5 || ^6 || ^7"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"react-dom": { "react-dom": {
@@ -11645,9 +11660,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/webrtc-adapter": { "node_modules/webrtc-adapter": {
"version": "9.0.5", "version": "9.0.6",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz", "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==", "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"sdp": "^3.2.0" "sdp": "^3.2.0"
+10 -10
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.27.0", "version": "1.28.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -15,33 +15,33 @@
"check": "prettier --check ./src" "check": "prettier --check ./src"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6", "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.2.11", "@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5", "@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4", "@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.21", "@livekit/components-react": "2.9.23",
"@livekit/components-styles": "1.2.0", "@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2", "@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14", "@mediapipe/tasks-vision": "0.10.14",
"@pandacss/preset-panda": "1.11.3", "@pandacss/preset-panda": "1.12.0",
"@react-types/overlays": "3.10.0", "@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.1", "@tanstack/react-query": "5.101.4",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "26.3.1", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.20.0", "livekit-client": "2.21.0",
"posthog-js": "1.395.0", "posthog-js": "1.409.5",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.8", "react-i18next": "17.0.10",
"react-stately": "3.48.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
@@ -2,6 +2,12 @@ import type { CaptureResult } from 'posthog-js'
const IGNORED_EXCEPTION_PATTERNS = [ const IGNORED_EXCEPTION_PATTERNS = [
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/, /ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
// livekit-client leaks the raw WebSocket error Event as an unhandled
// rejection when the signal ws errors after connect (Firefox-heavy,
// coincides with signal reconnects). Carries zero diagnostic content —
// the close reason is already logged by the SDK.
// See: https://github.com/livekit/client-sdk-js/issues/2062
/^Event captured as exception with keys: isTrusted$/,
] ]
const shouldIgnoreException = (value: unknown): boolean => const shouldIgnoreException = (value: unknown): boolean =>
@@ -137,6 +137,7 @@ export const captureMediaEvent = async (
| 'media-device-topology' | 'media-device-topology'
| 'media-device-success' | 'media-device-success'
| 'device-not-found' | 'device-not-found'
| 'device-in-use'
| 'permissions-denied' | 'permissions-denied'
| 'screen-share-permission-denied' | 'screen-share-permission-denied'
| 'silent-mic-detected' | 'silent-mic-detected'
@@ -23,7 +23,7 @@ export const ChatProvider = () => {
resetChatStore() resetChatStore()
}, []) }, [])
// Tigger the message notification (temporary) // Trigger the message notification (temporary)
useEffect(() => { useEffect(() => {
// TEMPORARY: This is a brittle workaround that relies on message count tracking // TEMPORARY: This is a brittle workaround that relies on message count tracking
// due to recent LiveKit useChat changes breaking the previous implementation // due to recent LiveKit useChat changes breaking the previous implementation
@@ -13,7 +13,7 @@ const controlBarRegion = cva({
mobile: { mobile: {
true: { true: {
justifyContent: 'center', justifyContent: 'center',
width: '330px', width: '100%',
}, },
}, },
}, },
@@ -1,5 +1,5 @@
import { useToast } from 'react-aria' import { useToast } from 'react-aria'
import { useRef } from 'react' import { useMemo, useRef } from 'react'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import Source = Track.Source import Source = Track.Source
@@ -11,6 +11,7 @@ import { Div } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer' import { StyledToastContainer } from './StyledToastContainer'
import { setPinnedTrack } from '@/stores/layout' import { setPinnedTrack } from '@/stores/layout'
import { useParticipantTracks } from '@livekit/components-react'
const ClickableToast = styled(RACButton, { const ClickableToast = styled(RACButton, {
base: { base: {
@@ -30,13 +31,17 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
) )
const participant = props.toast.content.participant const participant = props.toast.content.participant
if (!participant) return const [cameraTrack] = useParticipantTracks(
[Source.Camera],
participant?.identity
)
const trackReference = { const trackReference = useMemo(
participant, () => cameraTrack ?? { participant, source: Source.Camera },
publication: participant.getTrackPublication(Source.Camera), [cameraTrack, participant]
source: Source.Camera, )
}
if (!participant) return
return ( return (
<StyledToastContainer {...toastProps} ref={ref}> <StyledToastContainer {...toastProps} ref={ref}>
@@ -1,11 +1,9 @@
import { Participant, Track } from 'livekit-client' import { Participant, Track } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTrackMutedIndicator } from '@livekit/components-react' import { useTrackMutedIndicator } from '@livekit/components-react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useState } from 'react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { RiMicLine, RiMicOffLine } from '@remixicon/react' import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog' import { openMuteDialog } from '@/stores/muteDialog'
export const MuteButton = ({ participant }: { participant: Participant }) => { export const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
@@ -15,31 +13,18 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
source: Track.Source.Microphone, source: Track.Source.Microphone,
}) })
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
return ( return (
<> <Button
<Button isDisabled={isMuted}
isDisabled={isMuted} size={'sm'}
size={'sm'} variant={'primaryTextDark'}
variant={'primaryTextDark'} square
square onPress={() => openMuteDialog(participant)}
onPress={() => setIsAlertOpen(true)} tooltip={t('muteParticipant', { name })}
tooltip={t('muteParticipant', { name })} >
> {!isMuted ? <RiMicLine /> : <RiMicOffLine />}
{!isMuted ? <RiMicLine /> : <RiMicOffLine />} </Button>
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -19,13 +19,11 @@ import {
import Source = Track.Source import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react' import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { useState } from 'react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton' import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge' import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge' import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog' import { openMuteDialog } from '@/stores/muteDialog'
import { ParticipantName } from './ParticipantName' import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = { type MicIndicatorProps = {
@@ -34,7 +32,6 @@ type MicIndicatorProps = {
const MicIndicator = ({ participant }: MicIndicatorProps) => { const MicIndicator = ({ participant }: MicIndicatorProps) => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const { muteParticipant } = useMuteParticipant()
const { isMuted } = useTrackMutedIndicator({ const { isMuted } = useTrackMutedIndicator({
participant: participant, participant: participant,
source: Source.Microphone, source: Source.Microphone,
@@ -42,7 +39,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
const canMute = useCanMute(participant) const canMute = useCanMute(participant)
const isSpeaking = useIsSpeaking(participant) const isSpeaking = useIsSpeaking(participant)
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
const label = isLocal(participant) const label = isLocal(participant)
@@ -52,46 +48,34 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
}) })
return ( return (
<> <Button
<Button square
square variant="greyscale"
variant="greyscale" size="sm"
size="sm" tooltip={label}
tooltip={label} aria-label={label}
aria-label={label} isDisabled={isMuted || !canMute}
isDisabled={isMuted || !canMute} onPress={async () =>
onPress={async () => !isMuted && isLocal(participant)
!isMuted && isLocal(participant) ? await (participant as LocalParticipant)?.setMicrophoneEnabled(false)
? await (participant as LocalParticipant)?.setMicrophoneEnabled( : openMuteDialog(participant)
false }
) data-attr="participants-mute"
: setIsAlertOpen(true) >
} {isMuted ? (
data-attr="participants-mute" <RiMicOffFill color={'gray'} aria-hidden={true} />
> ) : (
{isMuted ? ( <RiMicFill
<RiMicOffFill color={'gray'} aria-hidden={true} /> className={css({
) : ( color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
<RiMicFill animation: isSpeaking
className={css({ ? 'pulse_background 800ms infinite'
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50', : undefined,
animation: isSpeaking })}
? 'pulse_background 800ms infinite' aria-hidden={true}
: undefined, />
})} )}
aria-hidden={true} </Button>
/>
)}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -1,4 +1,3 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react' import { RiEmotionLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
@@ -7,6 +6,8 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { REACTIONS_TOOLBAR_ID } from '../constants' import { REACTIONS_TOOLBAR_ID } from '../constants'
import { useReactionsToolbar } from '../hooks/useReactionsToolbar' import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const focusReactionsToolbar = () => { const focusReactionsToolbar = () => {
document document
@@ -17,35 +18,44 @@ const focusReactionsToolbar = () => {
export const REACTIONS_TOGGLE_ID = 'reactions-toggle' export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
export const ReactionsToggle = () => { /* eslint-disable react-refresh/only-export-components */
export const reactionShortcutHandler = () => {
if (layoutStore.showReactionsToolbar) {
focusReactionsToolbar()
} else {
layoutStore.showReactionsToolbar = true
}
}
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const ReactionsToggle = ({
variant = 'primaryDark',
onPress,
...props
}: Props) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { isOpen, toggle } = useReactionsToolbar() const { isOpen, toggle } = useReactionsToolbar()
const handleShortcut = useCallback(() => {
if (layoutStore.showReactionsToolbar) {
focusReactionsToolbar()
} else {
layoutStore.showReactionsToolbar = true
}
}, [])
useRegisterKeyboardShortcut({ useRegisterKeyboardShortcut({
id: 'reaction', id: 'reaction',
handler: handleShortcut, handler: reactionShortcutHandler,
}) })
return ( return (
<ToggleButton <ToggleButton
{...props}
id={REACTIONS_TOGGLE_ID} id={REACTIONS_TOGGLE_ID}
data-attr="reactions-toggle" data-attr="reactions-toggle"
square square
variant="primaryDark" variant={variant}
aria-label={t('button')} aria-label={t('button')}
aria-expanded={isOpen} aria-expanded={isOpen}
tooltip={t('button')} tooltip={t('button')}
isSelected={isOpen} isSelected={isOpen}
onChange={toggle} onChange={toggle}
onPress={onPress}
> >
<RiEmotionLine /> <RiEmotionLine />
</ToggleButton> </ToggleButton>
@@ -1,17 +1,20 @@
import { useStartRecording, useStopRecording } from '@/features/recording' import { useStartRecording, useStopRecording } from '@/features/recording'
import { recordingStore } from '@/stores/recording' import { recordingStore } from '@/stores/recording'
import { captureEvent } from '@/features/analytics/telemetry'
export const useMutateRecording = () => { export const useMutateRecording = () => {
const { mutateAsync: startRecording, isPending: isPendingToStart } = const { mutateAsync: startRecording, isPending: isPendingToStart } =
useStartRecording({ useStartRecording({
onError: () => { onError: () => {
recordingStore.isErrorDialogOpen = 'start' recordingStore.isErrorDialogOpen = 'start'
captureEvent('error-starting-recording')
}, },
}) })
const { mutateAsync: stopRecording, isPending: isPendingToStop } = const { mutateAsync: stopRecording, isPending: isPendingToStop } =
useStopRecording({ useStopRecording({
onError: () => { onError: () => {
recordingStore.isErrorDialogOpen = 'stop' recordingStore.isErrorDialogOpen = 'stop'
captureEvent('error-stopping-recording')
}, },
}) })
@@ -6,12 +6,15 @@ import {
usePersistentUserChoices, usePersistentUserChoices,
} from '@livekit/components-react' } from '@livekit/components-react'
import { import {
ConnectionError,
ConnectionErrorReason,
DisconnectReason, DisconnectReason,
MediaDeviceFailure, MediaDeviceFailure,
Room, Room,
type RoomOptions, type RoomOptions,
VideoPresets, VideoPresets,
} from 'livekit-client' } from 'livekit-client'
import { getMediaDeviceFailure } from '@/features/rooms/livekit/utils/mediaPermissions'
import { keys } from '@/api/queryKeys' import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
@@ -25,7 +28,11 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices' import { LocalUserChoices } from '@/stores/userChoices'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' import {
captureEvent,
captureMediaEvent,
reportError,
} from '@/features/analytics/telemetry'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit' import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile' import { useIsMobile } from '@/utils/useIsMobile'
@@ -225,8 +232,18 @@ export const Conference = ({
backgroundColor: 'primaryDark.50 !important', backgroundColor: 'primaryDark.50 !important',
})} })}
onError={(e) => { onError={(e) => {
const failure = MediaDeviceFailure.getFailure(e) const failure = getMediaDeviceFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return if (failure && failure !== MediaDeviceFailure.Other) return
// connect() was aborted by a disconnect() before the join completed
if (
e instanceof ConnectionError &&
e.reason === ConnectionErrorReason.Cancelled
) {
void captureEvent('connection-cancelled')
return
}
reportError('livekit_room_error', e, { reportError('livekit_room_error', e, {
path: 'connect_publish', path: 'connect_publish',
}) })
@@ -28,6 +28,7 @@ import {
userChoicesStore, userChoicesStore,
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice' import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse'
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing' import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
import { useJoinTracks } from '../livekit/hooks/useJoinTracks' import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
import { SilentMicDetector } from './SilentMicDetector' import { SilentMicDetector } from './SilentMicDetector'
@@ -218,12 +219,14 @@ const switchTrackDevice =
function getPreviewMessages({ function getPreviewMessages({
cameraFound, cameraFound,
cameraDenied, cameraDenied,
cameraInUse,
micDenied, micDenied,
videoEnabled, videoEnabled,
videoStarted, videoStarted,
}: { }: {
cameraFound: boolean cameraFound: boolean
cameraDenied: boolean cameraDenied: boolean
cameraInUse: boolean
micDenied: boolean micDenied: boolean
videoEnabled: boolean videoEnabled: boolean
videoStarted: boolean videoStarted: boolean
@@ -235,6 +238,9 @@ function getPreviewMessages({
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted' const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
return { hint: key, permissionsButtonLabel: key } return { hint: key, permissionsButtonLabel: key }
} }
if (cameraInUse) {
return { hint: 'cameraInUse', permissionsButtonLabel: null }
}
if (!videoEnabled) { if (!videoEnabled) {
return { hint: 'cameraDisabled', permissionsButtonLabel: null } return { hint: 'cameraDisabled', permissionsButtonLabel: null }
} }
@@ -328,18 +334,20 @@ const VideoPreview = ({
const cameraDenied = useCannotUseDevice('videoinput') const cameraDenied = useCannotUseDevice('videoinput')
const micDenied = useCannotUseDevice('audioinput') const micDenied = useCannotUseDevice('audioinput')
const cameraMissing = useDeviceMissing('videoinput') const cameraMissing = useDeviceMissing('videoinput')
const cameraInUse = useDeviceInUse('videoinput')
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled) const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
const { hint, permissionsButtonLabel } = getPreviewMessages({ const { hint, permissionsButtonLabel } = getPreviewMessages({
cameraFound: !cameraMissing, cameraFound: !cameraMissing,
cameraDenied, cameraDenied,
cameraInUse,
micDenied, micDenied,
videoEnabled, videoEnabled,
videoStarted, videoStarted,
}) })
const isError = cameraMissing || cameraDenied const isError = cameraMissing || cameraDenied || cameraInUse
return ( return (
<div className={styles.previewFrame}> <div className={styles.previewFrame}>
@@ -15,7 +15,8 @@ const Card = styled('div', {
marginTop: '1.5rem', marginTop: '1.5rem',
borderRadius: '0.25rem', borderRadius: '0.25rem',
boxShadow: '', boxShadow: '',
minWidth: '380px', width: '100%',
maxWidth: '380px',
minHeight: '196px', minHeight: '196px',
}, },
}) })
@@ -37,8 +38,11 @@ const ratingButtonRecipe = cva({
color: 'initial', color: 'initial',
border: 'none', border: 'none',
borderRadius: 0, borderRadius: 0,
padding: '0.5rem 0.85rem', padding: { base: '0.5rem 0.25rem', xsm: '0.5rem 0.85rem' },
flexGrow: '1', flexGrow: '1',
flexBasis: 0,
minWidth: 0,
textAlign: 'center',
cursor: 'pointer', cursor: 'pointer',
}, },
variants: { variants: {
@@ -99,7 +103,9 @@ const OpenFeedback = ({
return ( return (
<Card> <Card>
<H lvl={3}>{t('question')}</H> <H lvl={3} centered>
{t('question')}
</H>
<TextArea <TextArea
id="feedbackInput" id="feedbackInput"
name="feedback" name="feedback"
@@ -168,7 +174,9 @@ const RateQuality = ({
return ( return (
<Card> <Card>
<H lvl={3}>{t('question')}</H> <H lvl={3} centered>
{t('question')}
</H>
<Bar> <Bar>
{[...Array(maxRating)].map((_, index) => ( {[...Array(maxRating)].map((_, index) => (
<RACButton <RACButton
@@ -228,7 +236,9 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
}} }}
> >
<VStack gap={0}> <VStack gap={0}>
<H lvl={3}>{t('heading')}</H> <H lvl={3} centered>
{t('heading')}
</H>
<Text as="p" variant="paragraph" centered> <Text as="p" variant="paragraph" centered>
{t('body')} {t('body')}
</Text> </Text>
@@ -0,0 +1,53 @@
import { useEffect } from 'react'
import { useSnapshot } from 'valtio'
import {
clearDeviceInUseForOtherDevice,
deviceAvailabilityStore,
} from '@/stores/deviceAvailability'
import { userChoicesStore } from '@/stores/userChoices'
import { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
import type { PermissionKind } from '@/stores/permissions'
const RETRY_INTERVAL_MS = 30_000
/**
* There is no browser event for "another app released the device", so
* while a device is flagged in use, re-probe it periodically. The probe
* targets the currently selected device (the flag is about what the app
* would acquire, not about any device of the kind). Only clears the flag
* (the toggle becomes usable again); it never re-enables the device on
* the user's behalf.
*/
export function useWatchDeviceReleased() {
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
const { audioDeviceId, videoDeviceId } = useSnapshot(userChoicesStore)
useWatchKind('camera', cameraInUse, videoDeviceId)
useWatchKind('microphone', microphoneInUse, audioDeviceId)
}
function useWatchKind(
kind: PermissionKind,
inUse: boolean,
selectedDeviceId?: string
) {
useEffect(() => {
if (inUse) clearDeviceInUseForOtherDevice(kind, selectedDeviceId)
}, [kind, inUse, selectedDeviceId])
useEffect(() => {
if (!inUse) return
let stopped = false
let timer: number | undefined
const tick = async () => {
await probeDeviceReleased(kind, selectedDeviceId || undefined)
if (!stopped) {
timer = window.setTimeout(tick, RETRY_INTERVAL_MS)
}
}
timer = window.setTimeout(tick, RETRY_INTERVAL_MS)
return () => {
stopped = true
window.clearTimeout(timer)
}
}, [kind, inUse, selectedDeviceId])
}
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver' import { connectionObserverStore } from '@/stores/connectionObserver'
import { HStack } from '@/styled-system/jsx' import { Stack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo' import { navigateTo } from '@/navigation/navigateTo'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
@@ -134,7 +134,11 @@ export const IsIdleDisconnectModal = () => {
</H> </H>
<Description /> <Description />
<Settings /> <Settings />
<HStack marginTop="2rem"> <Stack
direction={{ base: 'column', xsm: 'row' }}
align={{ base: 'stretch', xsm: 'center' }}
marginTop="2rem"
>
<Button <Button
onPress={() => { onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false connectionObserverStore.isIdleDisconnectModalOpen = false
@@ -148,7 +152,7 @@ export const IsIdleDisconnectModal = () => {
<Button onPress={close} size="sm" variant="primary"> <Button onPress={close} size="sm" variant="primary">
{t('stayButton')} {t('stayButton')}
</Button> </Button>
</HStack> </Stack>
</div> </div>
) )
}} }}
@@ -0,0 +1,28 @@
import { useRef } from 'react'
import { useSnapshot } from 'valtio'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { closeMuteDialog, muteDialogStore } from '@/stores/muteDialog'
import { MuteAlertDialog } from './MuteAlertDialog'
export const MuteAlertDialogProvider = () => {
const { participant } = useSnapshot(muteDialogStore)
const { muteParticipant } = useMuteParticipant()
const lastNameRef = useRef('')
if (participant) {
lastNameRef.current = participant.name || participant.identity
}
return (
<MuteAlertDialog
isOpen={!!participant}
name={lastNameRef.current}
onClose={closeMuteDialog}
onSubmit={() => {
const target = muteDialogStore.participant
if (!target) return
muteParticipant(target).then(closeMuteDialog)
}}
/>
)
}
@@ -44,7 +44,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
videoElement?: HTMLVideoElement videoElement?: HTMLVideoElement
videoElementLoaded?: boolean videoElementLoaded?: boolean
// Canvas containg the video processing result, of which we extract as stream. // Canvas containing the video processing result, of which we extract as stream.
outputCanvas?: HTMLCanvasElement outputCanvas?: HTMLCanvasElement
outputCanvasCtx?: CanvasRenderingContext2D outputCanvasCtx?: CanvasRenderingContext2D
@@ -55,7 +55,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
segmentationMaskCanvas?: HTMLCanvasElement segmentationMaskCanvas?: HTMLCanvasElement
segmentationMaskCanvasCtx?: CanvasRenderingContext2D segmentationMaskCanvasCtx?: CanvasRenderingContext2D
// Mask containg the inference result. // Mask containing the inference result.
segmentationMask?: ImageData segmentationMask?: ImageData
// The resized image of the video source. // The resized image of the video source.
@@ -105,9 +105,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
_initVirtualBackgroundImage() { _initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') { if (this.options.type !== 'virtual') {
throw new Error( return
'Virtual background is only supported for virtual background'
)
} }
const needsUpdate = const needsUpdate =
@@ -5,16 +5,18 @@ import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type PermissionNeededButtonProps = { type PermissionNeededButtonProps = {
tooltip?: string tooltip?: string | null
onPress?: () => void onPress?: (() => void) | null
ariaLabel?: string
} }
export const PermissionNeededButton = ({ export const PermissionNeededButton = ({
tooltip, tooltip,
onPress, onPress,
ariaLabel,
}: PermissionNeededButtonProps) => { }: PermissionNeededButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' }) const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
const label = tooltip ?? t('tooltip') const label = tooltip === undefined ? t('tooltip') : tooltip
return ( return (
<div <div
className={css({ className={css({
@@ -26,9 +28,13 @@ export const PermissionNeededButton = ({
})} })}
> >
<Button <Button
aria-label={tooltip ? label : t('ariaLabel')} aria-label={ariaLabel ?? label ?? t('ariaLabel')}
tooltip={label} tooltip={label ?? undefined}
onPress={onPress ?? (() => openPermissionsDialog())} onPress={
onPress === undefined
? () => openPermissionsDialog()
: (onPress ?? undefined)
}
variant="permission" variant="permission"
> >
<div <div
@@ -20,8 +20,10 @@ import { openPermissionsDialog } from '@/stores/permissions'
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic' import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing' import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { requestDevicePermission } from '../../../hooks/useJoinTracks' import { requestDevicePermission } from '../../../utils/mediaPermissions'
import { userChoicesStore } from '@/stores/userChoices'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons' import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut' import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import type { import type {
@@ -97,38 +99,51 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind) const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind) const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind) const deviceMissing = useDeviceMissing(kind)
const deviceInUse = useDeviceInUse(kind)
const explainDeviceInUse = deviceInUse && context === 'room'
const { status: silentMicStatus } = useSnapshot(silentMicStore) const { status: silentMicStatus } = useSnapshot(silentMicStore)
const silentMicWarning = const silentMicWarning =
kind === 'audioinput' && kind === 'audioinput' &&
silentMicStatus === 'silent' && silentMicStatus === 'silent' &&
!cannotUseDevice && !cannotUseDevice &&
!deviceMissing !deviceMissing &&
!deviceInUse
const deviceShortcut = useDeviceShortcut(kind) const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce() const announce = useScreenReaderAnnounce()
const isRequestingPermission = useRef(false) const isRequestingPermission = useRef(false)
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false) const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
const mediaPath = context === 'join' ? 'join_preview' : 'room'
const onPress = async () => { const onPress = async () => {
if (!enabled && deviceMissing) { if (!enabled && deviceMissing) {
setShowDeviceNotFound(true) setAlertError(MediaDeviceFailure.NotFound)
return return
} }
if (!cannotUseDevice) { if (!cannotUseDevice && !deviceInUse) {
toggle() toggle()
return return
} }
if (isRequestingPermission.current) return if (isRequestingPermission.current) return
isRequestingPermission.current = true isRequestingPermission.current = true
try { try {
const granted = await requestDevicePermission( const selectedDeviceId = cannotUseDevice
? undefined
: kind === 'videoinput'
? userChoicesStore.videoDeviceId
: userChoicesStore.audioDeviceId
const acquired = await requestDevicePermission(
kind, kind,
context === 'join' ? 'join_preview' : 'room' mediaPath,
selectedDeviceId || undefined
) )
if (granted) { if (acquired) {
toggle() toggle()
} else { } else if (cannotUseDevice) {
openPermissionsDialog(kind) openPermissionsDialog(kind)
} else if (explainDeviceInUse) {
setAlertError(MediaDeviceFailure.DeviceInUse)
} }
} finally { } finally {
isRequestingPermission.current = false isRequestingPermission.current = false
@@ -179,13 +194,34 @@ export const ToggleDevice = <T extends ToggleSource>({
return <ActiveSpeakerWrapper /> return <ActiveSpeakerWrapper />
} }
const getToggleTooltip = () => {
if (deviceMissing) return t(`deviceNotFound.${kind}`)
if (explainDeviceInUse) return t(`deviceInUse.${kind}`)
if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' })
return toggleLabel
}
const toggleTooltip = getToggleTooltip()
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{(cannotUseDevice || deviceMissing) && ( {(cannotUseDevice || deviceMissing) && (
<PermissionNeededButton <PermissionNeededButton
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined} tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
onPress={ onPress={
deviceMissing ? () => setShowDeviceNotFound(true) : undefined deviceMissing
? () => setAlertError(MediaDeviceFailure.NotFound)
: undefined
}
/>
)}
{deviceInUse && (
<PermissionNeededButton
tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : null}
ariaLabel={t(`deviceInUse.${kind}`)}
onPress={
explainDeviceInUse
? () => setAlertError(MediaDeviceFailure.DeviceInUse)
: null
} }
/> />
)} )}
@@ -204,22 +240,16 @@ export const ToggleDevice = <T extends ToggleSource>({
shySelected shySelected
onPress={onPress} onPress={onPress}
aria-label={toggleLabel} aria-label={toggleLabel}
tooltip={ tooltip={toggleTooltip}
deviceMissing
? t(`deviceNotFound.${kind}`)
: cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
{...computedToggleButtonProps} {...computedToggleButtonProps}
{...overrideToggleButtonProps} {...overrideToggleButtonProps}
> >
<Icon /> <Icon />
</ToggleButton> </ToggleButton>
<MediaDeviceErrorAlert <MediaDeviceErrorAlert
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null} error={alertError}
kind={kind} kind={kind}
onClose={() => setShowDeviceNotFound(false)} onClose={() => setAlertError(null)}
/> />
</div> </div>
) )
@@ -10,10 +10,18 @@ import {
showLowerHandToast, showLowerHandToast,
} from '@/features/notifications/utils' } from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const SPEAKING_DETECTION_DELAY = 3000 const SPEAKING_DETECTION_DELAY = 3000
export const HandToggle = () => { type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const HandToggle = ({
variant = 'primaryDark',
onPress,
...props
}: Props) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext() const room = useRoomContext()
@@ -74,12 +82,16 @@ export const HandToggle = () => {
})} })}
> >
<ToggleButton <ToggleButton
{...props}
square square
variant="primaryDark" variant={variant}
aria-label={t(tooltipLabel)} aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}
isSelected={isHandRaised} isSelected={isHandRaised}
onPress={handleToggle} onPress={(e) => {
handleToggle()
onPress?.(e)
}}
data-attr={`controls-hand-${tooltipLabel}`} data-attr={`controls-hand-${tooltipLabel}`}
> >
<RiHand /> <RiHand />
@@ -37,8 +37,8 @@ import { reportError } from '@/features/analytics/telemetry'
enum BlurRadius { enum BlurRadius {
NONE = 0, NONE = 0,
LIGHT = 5, LIGHT = 10,
NORMAL = 10, NORMAL = 20,
} }
const isSupported = BackgroundProcessorFactory.isSupported() const isSupported = BackgroundProcessorFactory.isSupported()
@@ -0,0 +1,21 @@
import { useSnapshot } from 'valtio'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
import { useCannotUseDevice } from './useCannotUseDevice'
import { useDeviceMissing } from './useDeviceMissing'
export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind)
if (cannotUseDevice || deviceMissing) return false
switch (kind) {
case 'videoinput':
return cameraInUse
case 'audioinput':
return microphoneInUse
default:
return false
}
}
@@ -10,16 +10,14 @@ import {
} from 'livekit-client' } from 'livekit-client'
import { BackgroundProcessorFactory } from '../components/blur' import { BackgroundProcessorFactory } from '../components/blur'
import { import {
classifyPermissionError,
isLikelySystemNotFound,
isSystemPermissionError, isSystemPermissionError,
noteGumSuccess,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
type PermissionKind, type PermissionKind,
} from '@/stores/permissions' } from '@/stores/permissions'
import { getOS } from '@/utils/os' import {
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' getMediaDeviceFailure,
noteDeviceReady,
onMediaPermissionError,
} from '../utils/mediaPermissions'
import { import {
saveAudioInputDeviceId, saveAudioInputDeviceId,
saveAudioInputEnabled, saveAudioInputEnabled,
@@ -39,64 +37,6 @@ const VOICE_AUDIO_CONSTRAINTS = {
sampleSize: 16, sampleSize: 16,
} as const } as const
const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
audioinput: 'microphone',
videoinput: 'camera',
}
type MediaPath = 'join_preview' | 'room'
const onMediaPermissionError = (
e: Error,
kind?: PermissionKind,
path: MediaPath = 'join_preview'
) => {
if (
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
) {
void classifyPermissionError(e, kind).then((scope) => {
if (scope === 'system') {
noteSystemPermissionDenied(kind)
} else {
notePermissionDeniedFromGum(kind)
}
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: scope,
os: getOS(),
})
})
return
}
if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) {
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
// settings, missing Android app permissions).
void isLikelySystemNotFound(e, kind).then((system) => {
if (system) {
noteSystemPermissionDenied(kind)
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: 'system',
os: getOS(),
})
return
}
captureMediaEvent('device-not-found', { path, kind })
})
return
}
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
reportError(
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
e,
{ path, kind }
)
}
// Module-level: effect dependencies, must be referentially stable. // Module-level: effect dependencies, must be referentially stable.
const disableAudio = () => saveAudioInputEnabled(false) const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false) const disableVideo = () => saveVideoInputEnabled(false)
@@ -104,24 +44,6 @@ const disableVideo = () => saveVideoInputEnabled(false)
const stopAll = (stream: MediaStream) => const stopAll = (stream: MediaStream) =>
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track) => track.stop())
export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput',
path: MediaPath = 'join_preview'
): Promise<boolean> => {
try {
const track =
kind === 'audioinput'
? await createLocalAudioTrack()
: await createLocalVideoTrack()
track.stop()
noteGumSuccess(PERMISSION_KIND[kind])
return true
} catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
return false
}
}
type WarmupState = { type WarmupState = {
audioReady: boolean audioReady: boolean
videoReady: boolean videoReady: boolean
@@ -158,11 +80,11 @@ function useWarmupPermissions(): WarmupState {
video: true, video: true,
}) })
) )
noteGumSuccess() noteDeviceReady()
bothReady() bothReady()
} catch (error) { } catch (error) {
if ( if (
MediaDeviceFailure.getFailure(error as Error) === getMediaDeviceFailure(error as Error) ===
MediaDeviceFailure.PermissionDenied && MediaDeviceFailure.PermissionDenied &&
!isSystemPermissionError(error) !isSystemPermissionError(error)
) { ) {
@@ -180,7 +102,7 @@ function useWarmupPermissions(): WarmupState {
.getUserMedia({ audio: true }) .getUserMedia({ audio: true })
.then((stream) => { .then((stream) => {
stopAll(stream) stopAll(stream)
noteGumSuccess('microphone') noteDeviceReady('microphone')
}) })
.catch((e) => onMediaPermissionError(e as Error, 'microphone')) .catch((e) => onMediaPermissionError(e as Error, 'microphone'))
.finally(() => .finally(() =>
@@ -190,7 +112,7 @@ function useWarmupPermissions(): WarmupState {
.getUserMedia({ video: true }) .getUserMedia({ video: true })
.then((stream) => { .then((stream) => {
stopAll(stream) stopAll(stream)
noteGumSuccess('camera') noteDeviceReady('camera')
}) })
.catch((e) => onMediaPermissionError(e as Error, 'camera')) .catch((e) => onMediaPermissionError(e as Error, 'camera'))
.finally(() => .finally(() =>
@@ -209,12 +131,14 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
enabled, enabled,
create, create,
permissionKind, permissionKind,
deviceId,
onFailure, onFailure,
}: { }: {
ready: boolean ready: boolean
enabled: boolean enabled: boolean
create: () => Promise<T> create: () => Promise<T>
permissionKind: PermissionKind permissionKind: PermissionKind
deviceId?: string
onFailure: () => void onFailure: () => void
}): T | null { }): T | null {
const [track, setTrack] = useState<T | null>(null) const [track, setTrack] = useState<T | null>(null)
@@ -227,21 +151,27 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
let cancelled = false let cancelled = false
create() create()
.then((newTrack) => { .then((newTrack) => {
noteGumSuccess(permissionKind)
if (cancelled) { if (cancelled) {
newTrack.stop() newTrack.stop()
return return
} }
noteDeviceReady(permissionKind)
setTrack(newTrack) setTrack(newTrack)
}) })
.catch((error) => { .catch((error) => {
onMediaPermissionError(error as Error, permissionKind) if (cancelled) return
onMediaPermissionError(
error as Error,
permissionKind,
'join_preview',
deviceId
)
onFailure() onFailure()
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, [ready, enabled, track, create, permissionKind, onFailure]) }, [ready, enabled, track, create, permissionKind, deviceId, onFailure])
// Release on toggle-off so the LED turns off. // Release on toggle-off so the LED turns off.
useEffect(() => { useEffect(() => {
@@ -315,6 +245,7 @@ export function useJoinTracks(): {
enabled: audioEnabled, enabled: audioEnabled,
create: createAudio, create: createAudio,
permissionKind: 'microphone', permissionKind: 'microphone',
deviceId: audioDeviceId,
onFailure: disableAudio, onFailure: disableAudio,
}) })
@@ -323,6 +254,7 @@ export function useJoinTracks(): {
enabled: videoEnabled, enabled: videoEnabled,
create: createVideo, create: createVideo,
permissionKind: 'camera', permissionKind: 'camera',
deviceId: videoDeviceId,
onFailure: disableVideo, onFailure: disableVideo,
}) })
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { MediaDeviceFailure, RoomEvent } from 'livekit-client' import {
type LocalTrackPublication,
MediaDeviceFailure,
RoomEvent,
Track,
} from 'livekit-client'
import { import {
PERMISSION_BY_DEVICE_KIND, PERMISSION_BY_DEVICE_KIND,
type PermissionDeniedScope, type PermissionDeniedScope,
@@ -10,8 +15,13 @@ import {
notePermissionDeniedFromGum, notePermissionDeniedFromGum,
noteSystemPermissionDenied, noteSystemPermissionDenied,
} from '@/stores/permissions' } from '@/stores/permissions'
import { syncDeviceAvailability } from '@/stores/deviceAvailability' import {
clearDeviceInUse,
noteDeviceInUse,
syncDeviceAvailability,
} from '@/stores/deviceAvailability'
import { captureMediaEvent } from '@/features/analytics/telemetry' import { captureMediaEvent } from '@/features/analytics/telemetry'
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
import { getOS } from '@/utils/os' import { getOS } from '@/utils/os'
type MediaDeviceAlert = { type MediaDeviceAlert = {
@@ -21,6 +31,11 @@ type MediaDeviceAlert = {
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null } const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
const PERMISSION_BY_SOURCE: Partial<Record<Track.Source, PermissionKind>> = {
[Track.Source.Camera]: 'camera',
[Track.Source.Microphone]: 'microphone',
}
const capturePermissionsDenied = ( const capturePermissionsDenied = (
scope: PermissionDeniedScope, scope: PermissionDeniedScope,
kind?: PermissionKind kind?: PermissionKind
@@ -49,7 +64,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
useEffect(() => { useEffect(() => {
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => { const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
const failure = MediaDeviceFailure.getFailure(error) const failure = getMediaDeviceFailure(error)
if (!failure) return if (!failure) return
if (failure != MediaDeviceFailure.Other) { if (failure != MediaDeviceFailure.Other) {
void captureMediaEvent('media-device-error', { void captureMediaEvent('media-device-error', {
@@ -63,6 +78,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind] const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
switch (failure) { switch (failure) {
case MediaDeviceFailure.DeviceInUse: case MediaDeviceFailure.DeviceInUse:
if (permissionKind) noteDeviceInUse(permissionKind)
setAlert({ error: failure, kind }) setAlert({ error: failure, kind })
break break
case MediaDeviceFailure.NotFound: case MediaDeviceFailure.NotFound:
@@ -92,9 +108,16 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
break break
} }
} }
const onTrackPublished = (publication: LocalTrackPublication) => {
const permissionKind = PERMISSION_BY_SOURCE[publication.source]
if (permissionKind) clearDeviceInUse(permissionKind)
}
room.on(RoomEvent.MediaDevicesError, onDeviceError) room.on(RoomEvent.MediaDevicesError, onDeviceError)
room.on(RoomEvent.LocalTrackPublished, onTrackPublished)
return () => { return () => {
room.off(RoomEvent.MediaDevicesError, onDeviceError) room.off(RoomEvent.MediaDevicesError, onDeviceError)
room.off(RoomEvent.LocalTrackPublished, onTrackPublished)
clearDeviceInUse()
} }
}, [room]) }, [room])
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton' import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton' import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions' import { MoreOptions } from './MoreOptions'
import { useRef } from 'react' import { RefObject, useMemo, useState } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useFullScreen } from '../../hooks/useFullScreen' import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl' import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
@@ -21,7 +21,14 @@ export function DesktopControlBar({
onDeviceError, onDeviceError,
}: Readonly<ControlBarAuxProps>) { }: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing() const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const [controlBarElement, setControlBarElement] =
useState<HTMLDivElement | null>(null)
const desktopControlBarEl = useMemo<RefObject<HTMLDivElement>>(
() => ({ current: controlBarElement }),
[controlBarElement]
)
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({}) const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
@@ -45,7 +52,7 @@ export function DesktopControlBar({
return ( return (
<div <div
ref={desktopControlBarEl} ref={setControlBarElement}
className={css({ className={css({
width: '100vw', width: '100vw',
display: 'flex', display: 'flex',
@@ -1,7 +1,7 @@
import { supportsScreenSharing } from '@livekit/components-core' import { supportsScreenSharing } from '@livekit/components-core'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { ControlBarAuxProps } from './ControlBar' import type { ControlBarAuxProps } from './ControlBar'
import React from 'react' import React, { useLayoutEffect, useRef, useState } from 'react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton' import { LeaveButton } from '../../components/controls/LeaveButton'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
@@ -26,7 +26,26 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl' import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { openSettingsDialog } from '@/stores/settings' import { openSettingsDialog } from '@/stores/settings'
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion' import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle' import {
ReactionsToggle,
reactionShortcutHandler,
} from '@/features/reactions/components/ReactionsToggle'
import { useSize } from '../../hooks/useResizeObserver'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useRoomContext } from '@livekit/components-react'
// Hand collapses first, then reactions; hidden toggles move into the menu.
const COLLAPSIBLE_COUNT = 2
// Layout-neutral measuring wrapper: inherits the region's gap and refuses to
// flex-shrink so measured widths are natural content widths.
const measuredRow = css({
display: 'inline-flex',
alignItems: 'center',
gap: 'inherit',
flexShrink: 0,
})
export function MobileControlBar({ export function MobileControlBar({
onDeviceError, onDeviceError,
@@ -36,50 +55,107 @@ export function MobileControlBar({
const browserSupportsScreenSharing = supportsScreenSharing() const browserSupportsScreenSharing = supportsScreenSharing()
const { toggleEffects } = useSidePanel() const { toggleEffects } = useSidePanel()
const containerRef = useRef<HTMLDivElement>(null)
const { width } = useSize(containerRef)
const barRef = useRef<HTMLDivElement>(null)
const { width: barWidth } = useSize(barRef)
const collapsibleRef = useRef<HTMLDivElement>(null)
const { width: collapsibleWidth } = useSize(collapsibleRef)
const [hiddenCount, setHiddenCount] = useState(0)
const calibration = useRef<{ essential: number; slot: number }>()
useLayoutEffect(() => {
if (hiddenCount === 0 && collapsibleWidth > 0 && barRef.current) {
const gap = parseFloat(getComputedStyle(barRef.current).columnGap) || 0
calibration.current = {
essential: barWidth - collapsibleWidth - gap,
slot: (collapsibleWidth + gap) / COLLAPSIBLE_COUNT,
}
}
if (!calibration.current || width <= 0) return
const { essential, slot } = calibration.current
const fits = Math.floor((width - essential) / slot)
const next = Math.min(
COLLAPSIBLE_COUNT,
Math.max(0, COLLAPSIBLE_COUNT - fits)
)
if (next !== hiddenCount) setHiddenCount(next)
}, [barWidth, collapsibleWidth, hiddenCount, width, setHiddenCount])
const hideHand = hiddenCount >= 1
const hideReactions = hiddenCount >= 2
const room = useRoomContext()
const { toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
useRegisterKeyboardShortcut({
id: 'raise-hand',
handler: toggleRaisedHand,
})
useRegisterKeyboardShortcut({
id: 'reaction',
handler: reactionShortcutHandler,
})
const { data } = useConfig() const { data } = useConfig()
const closeMenu = () => setIsMenuOpened(false)
return ( return (
<> <>
<div <div
className={css({ className={css({
width: '100vw', width: '100vw',
display: 'flex',
padding: '1.125rem', padding: '1.125rem',
justifyContent: 'center',
})} })}
> >
<ControlBarRegion mobile> <div
<LeaveButton /> ref={containerRef}
<AudioDevicesControl className={css({
onDeviceError={(error) => width: '100%',
onDeviceError?.({ source: Track.Source.Microphone, error }) display: 'flex',
} justifyContent: 'center',
hideMenu={true} })}
/> >
<VideoDeviceControl <ControlBarRegion mobile>
onDeviceError={(error) => <div ref={barRef} className={measuredRow}>
onDeviceError?.({ source: Track.Source.Camera, error }) <LeaveButton />
} <AudioDevicesControl
hideMenu={true} onDeviceError={(error) =>
/> onDeviceError?.({ source: Track.Source.Microphone, error })
<ReactionsToggle /> }
<HandToggle /> hideMenu={true}
<Button />
id="room-options-trigger" <VideoDeviceControl
square onDeviceError={(error) =>
variant="primaryDark" onDeviceError?.({ source: Track.Source.Camera, error })
aria-label={t('options.buttonLabel')} }
tooltip={t('options.buttonLabel')} hideMenu={true}
onPress={() => setIsMenuOpened(true)} />
> {/* Unmounted when empty so it doesn't leave a stray gap. */}
<RiMore2Line /> {!hideReactions && (
</Button> <div ref={collapsibleRef} className={measuredRow}>
</ControlBarRegion> <ReactionsToggle />
{!hideHand && <HandToggle />}
</div>
)}
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
onPress={() => setIsMenuOpened(true)}
>
<RiMore2Line />
</Button>
</div>
</ControlBarRegion>
</div>
</div> </div>
<ResponsiveMenu <ResponsiveMenu isOpened={isMenuOpened} onClosed={closeMenu}>
isOpened={isMenuOpened}
onClosed={() => setIsMenuOpened(false)}
>
<div <div
className={css({ className={css({
display: 'flex', display: 'flex',
@@ -98,6 +174,20 @@ export function MobileControlBar({
}, },
})} })}
> >
{hideReactions && (
<ReactionsToggle
variant="primaryTextDark"
description={true}
onPress={closeMenu}
/>
)}
{hideHand && (
<HandToggle
variant="primaryTextDark"
description={true}
onPress={closeMenu}
/>
)}
{browserSupportsScreenSharing && ( {browserSupportsScreenSharing && (
<ScreenShareToggle <ScreenShareToggle
onDeviceError={(error) => onDeviceError={(error) =>
@@ -105,25 +195,16 @@ export function MobileControlBar({
} }
variant="primaryTextDark" variant="primaryTextDark"
description={true} description={true}
onPress={() => setIsMenuOpened(false)} onPress={closeMenu}
/> />
)} )}
<ChatToggle <ChatToggle description={true} onPress={closeMenu} />
description={true} <ParticipantsToggle description={true} onPress={closeMenu} />
onPress={() => setIsMenuOpened(false)} <ToolsToggle description={true} onPress={closeMenu} />
/>
<ParticipantsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<ToolsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<Button <Button
onPress={() => { onPress={() => {
toggleEffects() toggleEffects()
setIsMenuOpened(false) closeMenu()
}} }}
variant="primaryTextDark" variant="primaryTextDark"
aria-label={t('options.items.effects')} aria-label={t('options.items.effects')}
@@ -140,7 +221,7 @@ export function MobileControlBar({
aria-label={t('options.items.feedback')} aria-label={t('options.items.feedback')}
description={true} description={true}
target="_blank" target="_blank"
onPress={() => setIsMenuOpened(false)} onPress={closeMenu}
> >
<RiMegaphoneLine size={20} /> <RiMegaphoneLine size={20} />
</LinkButton> </LinkButton>
@@ -148,7 +229,7 @@ export function MobileControlBar({
<Button <Button
onPress={() => { onPress={() => {
openSettingsDialog() openSettingsDialog()
setIsMenuOpened(false) closeMenu()
}} }}
variant="primaryTextDark" variant="primaryTextDark"
aria-label={t('options.items.settings')} aria-label={t('options.items.settings')}
@@ -157,7 +238,7 @@ export function MobileControlBar({
> >
<RiSettings3Line size={20} /> <RiSettings3Line size={20} />
</Button> </Button>
<CameraSwitchButton onPress={() => setIsMenuOpened(false)} /> <CameraSwitchButton onPress={closeMenu} />
</div> </div>
</div> </div>
</ResponsiveMenu> </ResponsiveMenu>
@@ -1,5 +1,6 @@
import { isWeb } from '@livekit/components-core' import { isWeb } from '@livekit/components-core'
import { Track } from 'livekit-client' import { MediaDeviceFailure, Track } from 'livekit-client'
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
import React, { useState } from 'react' import React, { useState } from 'react'
import { import {
ConnectionStateToast, ConnectionStateToast,
@@ -19,6 +20,7 @@ import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer
import { useNoiseReduction } from '../hooks/useNoiseReduction' import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription' import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider' import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal' import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals' import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea' import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
@@ -99,6 +101,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
return return
} }
} }
if (getMediaDeviceFailure(error) !== MediaDeviceFailure.Other) {
return
}
reportError('device_switch_failure', error, { reportError('device_switch_failure', error, {
at: 'ControlBar.onDeviceError', at: 'ControlBar.onDeviceError',
source, source,
@@ -144,6 +151,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionStateToast /> <ConnectionStateToast />
<RecordingProvider /> <RecordingProvider />
<SettingsDialogProvider /> <SettingsDialogProvider />
<MuteAlertDialogProvider />
<ReactionPortals /> <ReactionPortals />
</div> </div>
</> </>
@@ -0,0 +1,168 @@
import {
createLocalAudioTrack,
createLocalVideoTrack,
MediaDeviceFailure,
} from 'livekit-client'
import {
classifyPermissionError,
isLikelySystemNotFound,
noteGumSuccess,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
type PermissionKind,
} from '@/stores/permissions'
import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceAvailability'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os'
/**
* Shared handling of getUserMedia() outcomes, used by both:
* - the join preview (`useJoinTracks`: warmup + local track acquisition)
* - the in-room device toggle (`ToggleDevice`, when permission is missing)
*/
export type MediaPath = 'join_preview' | 'room'
const DEVICE_START_FAILURE =
/^(Starting (video|audio)input failed|Timeout starting (video|audio) source)/i
/**
* LiveKit only maps NotReadableError/TrackStartError to DeviceInUse.
* Firefox reports a device held by another app as
* `AbortError: Starting videoinput failed` (or audioinput), which LiveKit
* classifies as Other. Normalise it here; every classification in the app
* should go through this instead of MediaDeviceFailure.getFailure.
*/
export const getMediaDeviceFailure = (
error: Error
): MediaDeviceFailure | undefined => {
const failure = MediaDeviceFailure.getFailure(error)
if (
failure === MediaDeviceFailure.Other &&
error.name === 'AbortError' &&
DEVICE_START_FAILURE.test(error.message)
) {
return MediaDeviceFailure.DeviceInUse
}
return failure
}
export const PERMISSION_KIND: Record<
'audioinput' | 'videoinput',
PermissionKind
> = {
audioinput: 'microphone',
videoinput: 'camera',
}
export const noteDeviceReady = (kind?: PermissionKind) => {
noteGumSuccess(kind)
clearDeviceInUse(kind)
}
export const onMediaPermissionError = (
e: Error,
kind?: PermissionKind,
path: MediaPath = 'join_preview',
deviceId?: string
) => {
const failure = getMediaDeviceFailure(e)
if (failure === MediaDeviceFailure.PermissionDenied) {
void classifyPermissionError(e, kind).then((scope) => {
if (scope === 'system') {
noteSystemPermissionDenied(kind)
} else {
notePermissionDeniedFromGum(kind)
}
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: scope,
os: getOS(),
})
})
return
}
if (failure === MediaDeviceFailure.NotFound) {
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
// settings, missing Android app permissions).
void isLikelySystemNotFound(e, kind).then((system) => {
if (system) {
noteSystemPermissionDenied(kind)
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: 'system',
os: getOS(),
})
return
}
captureMediaEvent('device-not-found', { path, kind })
})
return
}
if (failure === MediaDeviceFailure.DeviceInUse) {
noteDeviceInUse(kind, deviceId)
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
return
}
// "Other" is still reported as an error.
reportError(
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
e,
{ path, kind }
)
}
/**
* Silent availability check for a device that was reported "in use":
* acquires and releases it without any error reporting, so it can be
* polled. Clears the in-use flag on success.
*/
export const probeDeviceReleased = async (
kind: PermissionKind,
deviceId?: string
): Promise<boolean> => {
try {
const constraint = deviceId ? { deviceId: { exact: deviceId } } : true
const stream = await navigator.mediaDevices.getUserMedia(
kind === 'camera' ? { video: constraint } : { audio: constraint }
)
stream.getTracks().forEach((track) => track.stop())
noteDeviceReady(kind)
return true
} catch {
return false
}
}
/**
* Triggers the browser permission prompt for one device kind by acquiring
* and immediately releasing a track. Resolves to whether access was granted.
*/
export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput',
path: MediaPath = 'join_preview',
deviceId?: string
): Promise<boolean> => {
try {
const track =
kind === 'audioinput'
? await createLocalAudioTrack({ deviceId })
: await createLocalVideoTrack({ deviceId })
track.stop()
noteDeviceReady(PERMISSION_KIND[kind])
return true
} catch (error) {
onMediaPermissionError(
error as Error,
PERMISSION_KIND[kind],
path,
deviceId
)
return false
}
}
@@ -1,7 +1,8 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx' import { Center, Stack, styled, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Rating } from '@/features/rooms/components/Rating.tsx' import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter' import { useLocation } from 'wouter'
import { useMemo } from 'react' import { useMemo } from 'react'
@@ -14,14 +15,18 @@ const Heading = styled('h1', {
fontStyle: 'normal', fontStyle: 'normal',
fontStretch: 'normal', fontStretch: 'normal',
fontOpticalSizing: 'auto', fontOpticalSizing: 'auto',
fontSize: '2.3rem', fontSize: { base: '1.75rem', xsm: '2.3rem' },
lineHeight: '2.5rem', lineHeight: { base: '2.125rem', xsm: '2.5rem' },
letterSpacing: '0', letterSpacing: '0',
paddingBottom: '2rem', paddingBottom: { base: '1.5rem', xsm: '2rem' },
textAlign: 'center', textAlign: 'center',
}, },
}) })
const buttonClass = css({
width: { base: '100%', xsm: 'auto' },
})
enum DisconnectReasonKey { enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity', DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved', ParticipantRemoved = 'participantRemoved',
@@ -54,19 +59,31 @@ const FeedbackRoute = () => {
return ( return (
<Screen layout="centered" footer={false}> <Screen layout="centered" footer={false}>
<Center> <Center width="100%">
<VStack> <VStack width="100%" paddingX="1rem">
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading> <Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<HStack> <Stack
direction={{ base: 'column', xsm: 'row' }}
width={{ base: '100%', xsm: 'auto' }}
maxWidth="380px"
>
{showBackButton && ( {showBackButton && (
<Button variant="secondary" onPress={() => window.history.back()}> <Button
variant="secondary"
className={buttonClass}
onPress={() => window.history.back()}
>
{t('feedback.back')} {t('feedback.back')}
</Button> </Button>
)} )}
<Button variant="primary" onPress={() => setLocation('/')}> <Button
variant="primary"
className={buttonClass}
onPress={() => setLocation('/')}
>
{t('feedback.home')} {t('feedback.home')}
</Button> </Button>
</HStack> </Stack>
<Rating metadata={metadata} /> <Rating metadata={metadata} />
</VStack> </VStack>
</Center> </Center>
@@ -16,6 +16,7 @@ import {
import { useConfig } from '@/api/useConfig.ts' import { useConfig } from '@/api/useConfig.ts'
import { LogLevel, setLogLevel } from 'livekit-client' import { LogLevel, setLogLevel } from 'livekit-client'
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability' import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle' import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
const BaseRoom = ({ children }: { children: ReactNode }) => { const BaseRoom = ({ children }: { children: ReactNode }) => {
@@ -49,6 +50,7 @@ const Room = () => {
useKeyboardShortcuts() useKeyboardShortcuts()
useWatchDeviceAvailability() useWatchDeviceAvailability()
useWatchDeviceReleased()
const clearRouterState = () => { const clearRouterState = () => {
if (window?.history?.state) { if (window?.history?.state) {
+5
View File
@@ -16,6 +16,10 @@
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.", "videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist." "audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
}, },
"deviceInUse": {
"videoinput": "Kamera nicht verfügbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
"audioinput": "Mikrofon nicht verfügbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet."
},
"settings": { "settings": {
"audio": "Audioeinstellungen", "audio": "Audioeinstellungen",
"video": "Videoeinstellungen" "video": "Videoeinstellungen"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "Kamera ist deaktiviert.", "cameraDisabled": "Kamera ist deaktiviert.",
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.", "cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
"cameraInUse": "Deine Kamera ist nicht verfügbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
"cameraStarting": "Kamera wird gestartet…", "cameraStarting": "Kamera wird gestartet…",
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?", "cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?", "cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
+5
View File
@@ -16,6 +16,10 @@
"videoinput": "No camera detected. Check that it is properly wired.", "videoinput": "No camera detected. Check that it is properly wired.",
"audioinput": "No microphone detected. Check that it is properly wired." "audioinput": "No microphone detected. Check that it is properly wired."
}, },
"deviceInUse": {
"videoinput": "Camera unavailable: it is probably in use by another application or browser tab.",
"audioinput": "Microphone unavailable: it is probably in use by another application or browser tab."
},
"settings": { "settings": {
"audio": "Audio settings", "audio": "Audio settings",
"video": "Video settings" "video": "Video settings"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "Camera is disabled.", "cameraDisabled": "Camera is disabled.",
"cameraNotFound": "No camera detected. Check that it is properly plugged in.", "cameraNotFound": "No camera detected. Check that it is properly plugged in.",
"cameraInUse": "Your camera is unavailable. It is probably being used by another application or browser tab.",
"cameraStarting": "Camera is starting…", "cameraStarting": "Camera is starting…",
"cameraNotGranted": "Would you like others to be able to see you during the meeting?", "cameraNotGranted": "Would you like others to be able to see you during the meeting?",
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?", "cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
+5
View File
@@ -16,6 +16,10 @@
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.", "videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché." "audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
}, },
"deviceInUse": {
"videoinput": "Caméra indisponible : elle est probablement utilisée par une autre application ou un autre onglet.",
"audioinput": "Microphone indisponible : il est probablement utilisé par une autre application ou un autre onglet."
},
"settings": { "settings": {
"audio": "Paramètres audio", "audio": "Paramètres audio",
"video": "Paramètres video" "video": "Paramètres video"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "La caméra est désactivée.", "cameraDisabled": "La caméra est désactivée.",
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.", "cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
"cameraInUse": "Votre caméra n'est pas disponible. Elle est probablement utilisée par une autre application ou un autre onglet.",
"cameraStarting": "La caméra va démarrer…", "cameraStarting": "La caméra va démarrer…",
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?", "cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?", "cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
+5
View File
@@ -16,6 +16,10 @@
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.", "videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten." "audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
}, },
"deviceInUse": {
"videoinput": "Camera niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
"audioinput": "Microfoon niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad."
},
"settings": { "settings": {
"audio": "Audio-instellingen", "audio": "Audio-instellingen",
"video": "Video-instellingen" "video": "Video-instellingen"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "Camera is uitgeschakeld.", "cameraDisabled": "Camera is uitgeschakeld.",
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.", "cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
"cameraInUse": "Je camera is niet beschikbaar. Deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
"cameraStarting": "Camera wordt ingeschakeld…", "cameraStarting": "Camera wordt ingeschakeld…",
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?", "cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?", "cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
+57 -3
View File
@@ -1,14 +1,68 @@
import { proxy } from 'valtio' import { proxy } from 'valtio'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import type { PermissionKind } from './permissions'
// Device presence (not permission): enumerateDevices() exposes kinds // Device availability (not permission):
// before any grant. Optimistic defaults until the first sync. // - presence: enumerateDevices() exposes kinds before any grant.
export const deviceAvailabilityStore = proxy({ // Optimistic defaults until the first sync.
// - in use: the device exists and is allowed, but getUserMedia() failed
// because another application or tab is holding it. The id of the
// affected device is kept (when known) so that a selection change can
// invalidate a flag that no longer concerns the selected device.
export const deviceAvailabilityStore = proxy<{
hasCamera: boolean
hasMicrophone: boolean
cameraInUse: boolean
microphoneInUse: boolean
cameraInUseDeviceId?: string
microphoneInUseDeviceId?: string
synced: boolean
}>({
hasCamera: true, hasCamera: true,
hasMicrophone: true, hasMicrophone: true,
cameraInUse: false,
microphoneInUse: false,
synced: false, synced: false,
}) })
const IN_USE_KEY: Record<PermissionKind, 'cameraInUse' | 'microphoneInUse'> = {
camera: 'cameraInUse',
microphone: 'microphoneInUse',
}
const IN_USE_DEVICE_KEY: Record<
PermissionKind,
'cameraInUseDeviceId' | 'microphoneInUseDeviceId'
> = {
camera: 'cameraInUseDeviceId',
microphone: 'microphoneInUseDeviceId',
}
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
export const noteDeviceInUse = (kind?: PermissionKind, deviceId?: string) => {
for (const k of kind ? [kind] : ALL_KINDS) {
deviceAvailabilityStore[IN_USE_KEY[k]] = true
deviceAvailabilityStore[IN_USE_DEVICE_KEY[k]] = deviceId
}
}
export const clearDeviceInUse = (kind?: PermissionKind) => {
for (const k of kind ? [kind] : ALL_KINDS) {
deviceAvailabilityStore[IN_USE_KEY[k]] = false
deviceAvailabilityStore[IN_USE_DEVICE_KEY[k]] = undefined
}
}
export const clearDeviceInUseForOtherDevice = (
kind: PermissionKind,
selectedDeviceId?: string
) => {
const affected = deviceAvailabilityStore[IN_USE_DEVICE_KEY[kind]]
if (!affected || !selectedDeviceId || affected === selectedDeviceId) return
clearDeviceInUse(kind)
}
export const syncDeviceAvailability = async (): Promise<void> => { export const syncDeviceAvailability = async (): Promise<void> => {
try { try {
const devices = await navigator.mediaDevices.enumerateDevices() const devices = await navigator.mediaDevices.enumerateDevices()
+18
View File
@@ -0,0 +1,18 @@
import { proxy, ref } from 'valtio'
import type { Participant } from 'livekit-client'
type State = {
participant: Participant | null
}
export const muteDialogStore = proxy<State>({
participant: null,
})
export const openMuteDialog = (participant: Participant) => {
muteDialogStore.participant = ref(participant)
}
export const closeMuteDialog = () => {
muteDialogStore.participant = null
}
+18 -8
View File
@@ -5,16 +5,26 @@ import { visualizer } from 'rollup-plugin-visualizer'
import svgr from 'vite-plugin-svgr' import svgr from 'vite-plugin-svgr'
import { viteStaticCopy } from 'vite-plugin-static-copy' import { viteStaticCopy } from 'vite-plugin-static-copy'
const mediapipeVersion: string = JSON.parse( const readPackageJson = (path: string) =>
readFileSync( JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf-8'))
new URL(
'./node_modules/@mediapipe/tasks-vision/package.json', const mediapipeVersion: string = readPackageJson(
import.meta.url './node_modules/@mediapipe/tasks-vision/package.json'
),
'utf-8'
)
).version ).version
const livekitMediapipeVersion: string = readPackageJson(
'./node_modules/@livekit/track-processors/package.json'
).dependencies['@mediapipe/tasks-vision']
if (mediapipeVersion !== livekitMediapipeVersion) {
throw new Error(
`@mediapipe/tasks-vision@${mediapipeVersion} is installed, but ` +
`@livekit/track-processors declares "${livekitMediapipeVersion}". ` +
`The two must stay in sync: pin "@mediapipe/tasks-vision" to ` +
`"${livekitMediapipeVersion}" in package.json.`
)
}
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd()) const env = loadEnv(mode, process.cwd())
+20 -10
View File
@@ -1,15 +1,15 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.27.0", "version": "1.28.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.27.0", "version": "1.28.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.0", "@html-to/text-cli": "0.6.1",
"mjml": "5.4.0" "mjml": "5.4.0"
} }
}, },
@@ -52,14 +52,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@html-to/text-cli": { "node_modules/@html-to/text-cli": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.1.tgz",
"integrity": "sha512-JwlrCBccUM/QkUpc37P+qG+hpkXRbWOVcHd9vM+5D+O82Ak2p8anGRxisr33//aJzlkYNKNg2I8LDh3Bx2tBPg==", "integrity": "sha512-fnbLS8bra4BkGinXWzUKfalqLPYmz8E2ABT+TgHUZ4inhAUYF2rBEEctOKqZ6FgaJF1iZ//KBBLKVz6nm3RbhA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@selderee/plugin-htmlparser2": "~0.12.0", "@selderee/plugin-htmlparser2": "~0.12.0",
"aspargvs": "~0.7.0", "aspargvs": "~0.7.0",
"deepmerge-ts": "^7.1.5", "deepmerge-ts": "^8.0.1",
"htmlparser2": "^10.1.0", "htmlparser2": "^10.1.0",
"selderee": "~0.12.0" "selderee": "~0.12.0"
}, },
@@ -656,9 +656,19 @@
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "7.1.5", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", "integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==",
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.27.0", "version": "1.28.0",
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.0", "@html-to/text-cli": "0.6.1",
"mjml": "5.4.0" "mjml": "5.4.0"
}, },
"private": true, "private": true,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.27.0", "version": "1.28.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sdk", "name": "sdk",
"version": "1.27.0", "version": "1.28.0",
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
"./library", "./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.27.0", "version": "1.28.0",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "summary" name = "summary"
version = "1.27.0" version = "1.28.0"
dependencies = [ dependencies = [
"fastapi[standard]>=0.105.0", "fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0", "uvicorn>=0.24.0",
+8 -1
View File
@@ -1,9 +1,10 @@
"""Analytics classes.""" """Analytics classes."""
import json import json
import socket
import time import time
from collections import Counter from collections import Counter
from functools import lru_cache from functools import cached_property, lru_cache
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
import redis import redis
@@ -33,6 +34,10 @@ class Analytics:
logger.info("Initialize analytics client") logger.info("Initialize analytics client")
self._client = Posthog(settings.posthog_api_key, settings.posthog_api_host) self._client = Posthog(settings.posthog_api_key, settings.posthog_api_host)
@cached_property
def _hostname(self):
return socket.gethostname()
@property @property
def is_disabled(self): def is_disabled(self):
"""Check if analytics client is disabled or not configured.""" """Check if analytics client is disabled or not configured."""
@@ -43,6 +48,8 @@ class Analytics:
if self.is_disabled: if self.is_disabled:
return return
properties = {**(properties or {}), "_hostname": self._hostname}
try: try:
self._client.capture( self._client.capture(
event_name, distinct_id=distinct_id, properties=properties event_name, distinct_id=distinct_id, properties=properties
+1
View File
@@ -83,6 +83,7 @@ class Settings(BaseSettings):
aws_s3_access_key_id: str aws_s3_access_key_id: str
aws_s3_secret_access_key: SecretStr aws_s3_secret_access_key: SecretStr
aws_s3_secure_access: bool = True aws_s3_secure_access: bool = True
aws_s3_region_name: str | None = None
aws_transcript_path: str = "transcripts" aws_transcript_path: str = "transcripts"
aws_summary_path: str = "summaries" aws_summary_path: str = "summaries"
+1
View File
@@ -282,6 +282,7 @@ class FileService:
access_key=settings.aws_s3_access_key_id, access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(), secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access, secure=settings.aws_s3_secure_access,
region=settings.aws_s3_region_name,
) )
self._bucket_name = settings.aws_storage_bucket_name self._bucket_name = settings.aws_storage_bucket_name
+1 -1
View File
@@ -26,7 +26,7 @@ class RecordingMetadata(BaseModel):
cloud_storage_url: Url = Field( cloud_storage_url: Url = Field(
title="Cloud Storage URL", title="Cloud Storage URL",
description="The URL of the metadata file for speaker assignement.", description="The URL of the metadata file for speaker assignment.",
) )
started_at: AwareDatetime = Field(title="Start time of the recording to transcribe") started_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe") ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")