mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 13:48:26 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e2bd8e4b3 | |||
| cec2eb5a10 | |||
| 2858d141f4 | |||
| f10429581b | |||
| b3369cddf7 | |||
| cfffadc780 | |||
| 4ae31b3297 | |||
| 954991c7c3 | |||
| 6e2a7f0ca6 | |||
| 24404e4ea2 | |||
| f18d784615 | |||
| 1bfe6b8977 | |||
| 84845709d0 | |||
| e000377b5c | |||
| a9fa3eb4ba | |||
| 2ec3f54532 | |||
| d723c14d84 | |||
| e5f0b1c202 | |||
| 9e0d57a8c6 | |||
| 7ba0803b71 | |||
| beacfc3d3f | |||
| 52e5d99e83 | |||
| 15ca2b41b4 | |||
| e34f3dd219 | |||
| feb573551f | |||
| 1d0a0cc637 | |||
| eae93f771f | |||
| 45175a2a54 | |||
| 8b22059b18 | |||
| 87dbd8069d | |||
| c7420c59a3 | |||
| f46babfcbd | |||
| 7c465f2148 | |||
| d005f202c6 | |||
| a82023f8b0 |
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
Generated
+1
-1
@@ -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" },
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+4
-4
@@ -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]]
|
||||||
|
|||||||
Generated
+107
-92
@@ -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
@@ -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}>
|
||||||
|
|||||||
+11
-26
@@ -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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-5
@@ -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 =
|
||||||
|
|||||||
+12
-6
@@ -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
|
||||||
|
|||||||
+49
-19
@@ -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) {
|
||||||
|
|||||||
@@ -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?",
|
||||||
|
|||||||
@@ -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?",
|
||||||
|
|||||||
@@ -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 ?",
|
||||||
|
|||||||
@@ -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?",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
Generated
+20
-10
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.27.0",
|
"version": "1.28.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user