mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 21:58:29 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43e0a1f9ee | |||
| 91a91da1ef | |||
| 0e0485b694 | |||
| 7b95c3e6df | |||
| 2f908cdaf8 | |||
| 649211bdd8 | |||
| 7bf13245a6 | |||
| d656367fa7 | |||
| 414494d864 | |||
| f52c92f584 | |||
| bdf6163d45 | |||
| 15e9f509b1 | |||
| 13841573ea | |||
| 3f3c9337c8 | |||
| d7206e9e46 | |||
| e0e3b91f5d | |||
| d4fa88d9ca | |||
| 3c8b247a99 | |||
| 6b2d67cf61 |
+4
-6
@@ -8,12 +8,6 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(frontend) add 1080p sending resolution option #1660
|
|
||||||
- ✨(backend) add Traefik support via configurable media-auth url header #1649
|
|
||||||
- ✨(backend) update a room's attributes from the external API
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
||||||
@@ -228,6 +222,10 @@ and this project adheres to
|
|||||||
- ♿️(frontend) focus side panel container on open #1452
|
- ♿️(frontend) focus side panel container on open #1452
|
||||||
- 🐛(summary) whisper call error handling
|
- 🐛(summary) whisper call error handling
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) add screen share zoom controls #1498
|
||||||
|
|
||||||
## [1.23.0] - 2026-07-08
|
## [1.23.0] - 2026-07-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ RUN apk update && apk upgrade \
|
|||||||
musl \
|
musl \
|
||||||
musl-utils \
|
musl-utils \
|
||||||
zlib>=1.3.2-r0 \
|
zlib>=1.3.2-r0 \
|
||||||
libexpat>=2.8.4-r0 \
|
|
||||||
&& apk del curl
|
&& apk del curl
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|||||||
+1
-76
@@ -16,7 +16,7 @@ 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` – Update the access level and configuration of existing rooms.
|
* `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
||||||
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||||
|
|
||||||
#### Upcoming Features
|
#### Upcoming Features
|
||||||
@@ -310,67 +310,6 @@ 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:
|
||||||
@@ -447,17 +386,6 @@ 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: |
|
||||||
@@ -499,9 +427,6 @@ 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` – Update the access level and configuration of existing rooms.
|
* `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
||||||
* `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,67 +206,6 @@ 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:
|
||||||
@@ -288,17 +227,6 @@ 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: |
|
||||||
@@ -340,9 +268,6 @@ 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:
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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"
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ 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 RoomManagement
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
from core.services.room_roles import (
|
from core.services.room_roles import (
|
||||||
RoomRoleError,
|
RoomRoleError,
|
||||||
RoomRoleService,
|
RoomRoleService,
|
||||||
@@ -352,7 +356,26 @@ class RoomViewSet(
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
RoomManagement.sync_room_metadata(room)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
@@ -1053,10 +1076,9 @@ class RecordingViewSet(
|
|||||||
|
|
||||||
def _auth_get_original_url(self, request):
|
def _auth_get_original_url(self, request):
|
||||||
"""
|
"""
|
||||||
Extracts and parses the original URL from the configured header.
|
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||||
Raises PermissionDenied if the header is missing.
|
Raises PermissionDenied if the header is missing.
|
||||||
The original url is passed by the reverse proxy in the header named by the
|
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
|
|
||||||
See corresponding ingress configuration in Helm chart and read about the
|
See corresponding ingress configuration in Helm chart and read about the
|
||||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||||
is configured to do this.
|
is configured to do this.
|
||||||
@@ -1066,13 +1088,9 @@ class RecordingViewSet(
|
|||||||
reasons.
|
reasons.
|
||||||
"""
|
"""
|
||||||
# Extract the original URL from the request header
|
# Extract the original URL from the request header
|
||||||
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
|
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||||
if not original_url:
|
if not original_url:
|
||||||
logger.warning(
|
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||||
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
|
|
||||||
"to the header your reverse proxy sends.",
|
|
||||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
logger.debug("Original url: '%s'", original_url)
|
logger.debug("Original url: '%s'", original_url)
|
||||||
@@ -1397,8 +1415,7 @@ class FileViewSet(
|
|||||||
Authorize access based on the original URL of an Nginx subrequest
|
Authorize access based on the original URL of an Nginx subrequest
|
||||||
and user permissions. Returns a dictionary of URL parameters if authorized.
|
and user permissions. Returns a dictionary of URL parameters if authorized.
|
||||||
|
|
||||||
The original url is passed by the reverse proxy in the header named by the
|
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
|
|
||||||
See corresponding ingress configuration in Helm chart and read about the
|
See corresponding ingress configuration in Helm chart and read about the
|
||||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||||
is configured to do this.
|
is configured to do this.
|
||||||
@@ -1417,13 +1434,9 @@ class FileViewSet(
|
|||||||
- PermissionDenied if authorization fails.
|
- PermissionDenied if authorization fails.
|
||||||
"""
|
"""
|
||||||
# Extract the original URL from the request header
|
# Extract the original URL from the request header
|
||||||
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
|
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||||
if not original_url:
|
if not original_url:
|
||||||
logger.warning(
|
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||||
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
|
|
||||||
"to the header your reverse proxy sends.",
|
|
||||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
parsed_url = urlparse(original_url)
|
parsed_url = urlparse(original_url)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""External API endpoints"""
|
"""External API endpoints"""
|
||||||
|
|
||||||
import copy
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -26,7 +25,6 @@ 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 RoomManagement
|
|
||||||
|
|
||||||
from ..services.provisional_user_service import (
|
from ..services.provisional_user_service import (
|
||||||
ProvisionalUserCreationDisabledError,
|
ProvisionalUserCreationDisabledError,
|
||||||
@@ -144,7 +142,6 @@ 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.
|
||||||
@@ -157,12 +154,8 @@ 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,
|
||||||
@@ -196,39 +189,7 @@ 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):
|
def perform_create(self, serializer):
|
||||||
"""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: serializers.RoomSerializer):
|
|
||||||
"""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()
|
||||||
models.ResourceAccess.objects.create(
|
models.ResourceAccess.objects.create(
|
||||||
@@ -237,31 +198,27 @@ class RoomViewSet(
|
|||||||
role=models.RoleChoices.OWNER,
|
role=models.RoleChoices.OWNER,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
|
auth_method = type(self.request.successful_authenticator).__name__
|
||||||
|
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||||
|
|
||||||
def perform_update(self, serializer: serializers.RoomSerializer):
|
# Log for auditing
|
||||||
"""Persist the room update, sync it to LiveKit, then log and track it."""
|
logger.info(
|
||||||
|
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
|
||||||
previous_values = {
|
room.id,
|
||||||
"access_level": serializer.instance.access_level,
|
self.request.user.id,
|
||||||
"configuration": copy.deepcopy(serializer.instance.configuration),
|
client_id,
|
||||||
}
|
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if updated_fields:
|
analytics.capture(
|
||||||
RoomManagement.sync_room_metadata(room)
|
self.request.user,
|
||||||
|
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||||
self._track_room_event(
|
{
|
||||||
room,
|
"room_id": str(room.pk),
|
||||||
analytics.AnalyticsEvent.ROOM_UPDATED,
|
"access_level": room.access_level,
|
||||||
updated_fields=updated_fields,
|
"client_id": client_id,
|
||||||
previous_access_level=previous_values["access_level"],
|
"external_api": True,
|
||||||
|
"auth_method": auth_method,
|
||||||
|
"$set": {"email": self.request.user.email},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake resource user accesses for testing."""
|
"""Create fake resource user accesses for testing."""
|
||||||
@@ -95,6 +97,8 @@ class RecordingFactory(factory.django.DjangoModelFactory):
|
|||||||
recording=self, user=item[0], role=item[1]
|
recording=self, user=item[0], role=item[1]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake recording user accesses for testing."""
|
"""Create fake recording user accesses for testing."""
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class RecordingEventsService:
|
|||||||
recording_status = status_mapping.get(egress_status)
|
recording_status = status_mapping.get(egress_status)
|
||||||
if recording_status:
|
if recording_status:
|
||||||
try:
|
try:
|
||||||
RoomManagement.update_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_status": recording_status}
|
room_name, {"recording_status": recording_status}
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class WorkerServiceMediator:
|
|||||||
mode = recording.options.get("original_mode", None) or recording.mode
|
mode = recording.options.get("original_mode", None) or recording.mode
|
||||||
|
|
||||||
try:
|
try:
|
||||||
RoomManagement.update_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
RoomManagement.update_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
|
|||||||
@@ -30,10 +30,9 @@ class RoomNotFoundException(RoomManagementException):
|
|||||||
class RoomManagement:
|
class RoomManagement:
|
||||||
"""Service for managing LiveKit rooms."""
|
"""Service for managing LiveKit rooms."""
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def update_metadata(
|
async def update_metadata(
|
||||||
cls,
|
self,
|
||||||
room_name: str,
|
room_name: str,
|
||||||
metadata: Optional[Dict] = None,
|
metadata: Optional[Dict] = None,
|
||||||
remove_keys: Optional[list[str]] = None,
|
remove_keys: Optional[list[str]] = None,
|
||||||
@@ -91,9 +90,8 @@ class RoomManagement:
|
|||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def delete_room(cls, room_name: str):
|
async def delete_room(self, room_name: str):
|
||||||
"""Delete a LiveKit room and disconnect all participants.
|
"""Delete a LiveKit room and disconnect all participants.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -118,32 +116,3 @@ 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()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def sync_room_metadata(cls, 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:
|
|
||||||
cls.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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def delete_connection_test_room(room_name: str):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
RoomManagement.delete_room(room_name)
|
RoomManagement().delete_room(room_name)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
# Room may already be gone after empty/departure timeout.
|
# Room may already be gone after empty/departure timeout.
|
||||||
logger.info("Connection test room '%s' already gone.", room_name)
|
logger.info("Connection test room '%s' already gone.", room_name)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from urllib.parse import quote, urlparse
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.test import override_settings
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -144,59 +143,3 @@ def test_api_files_media_auth_own_file_deleted():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
|
||||||
def test_api_files_media_auth_custom_original_url_header():
|
|
||||||
"""
|
|
||||||
Authorization should honour the configured original-url header.
|
|
||||||
|
|
||||||
Covers the attachment subrequest path, which resolves the header separately
|
|
||||||
from the recording one. Reverse proxies other than nginx-ingress use
|
|
||||||
different headers: Traefik's ForwardAuth sends X-Forwarded-Uri and cannot
|
|
||||||
emit X-Original-URL at all.
|
|
||||||
"""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
file = factories.FileFactory(
|
|
||||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
|
||||||
update_upload_state=models.FileUploadStateChoices.READY,
|
|
||||||
creator=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
default_storage.save(file.file_key, BytesIO(b"my prose"))
|
|
||||||
|
|
||||||
original_url = f"http://localhost/media/{file.file_key:s}"
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/files/media-auth/", HTTP_X_FORWARDED_URI=original_url
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
|
||||||
def test_api_files_media_auth_default_header_ignored_when_reconfigured():
|
|
||||||
"""
|
|
||||||
Only the configured header should be honoured, never a hardcoded fallback.
|
|
||||||
"""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
file = factories.FileFactory(
|
|
||||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
|
||||||
update_upload_state=models.FileUploadStateChoices.READY,
|
|
||||||
creator=user,
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
original_url = f"http://localhost/media/{file.file_key:s}"
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.test import override_settings
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -283,63 +282,3 @@ def test_api_recordings_media_auth_success_administrator(mode):
|
|||||||
timeout=1,
|
timeout=1,
|
||||||
)
|
)
|
||||||
assert response.content.decode("utf-8") == "my prose"
|
assert response.content.decode("utf-8") == "my prose"
|
||||||
|
|
||||||
|
|
||||||
def test_api_recordings_media_auth_missing_header():
|
|
||||||
"""
|
|
||||||
Test that a subrequest without the configured original-url header is rejected.
|
|
||||||
"""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/recordings/media-auth/")
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
|
||||||
def test_api_recordings_media_auth_custom_original_url_header():
|
|
||||||
"""
|
|
||||||
Test that the header carrying the original URL can be configured.
|
|
||||||
|
|
||||||
Reverse proxies other than nginx-ingress use different headers: Traefik's
|
|
||||||
ForwardAuth sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
|
|
||||||
"""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
|
||||||
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/recordings/media-auth/", HTTP_X_FORWARDED_URI=original_url
|
|
||||||
)
|
|
||||||
|
|
||||||
# The header was read and parsed: we get as far as looking the recording up,
|
|
||||||
# rather than being rejected for a missing header.
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
|
||||||
def test_api_recordings_media_auth_default_header_ignored_when_reconfigured():
|
|
||||||
"""
|
|
||||||
Test that only the configured header is honoured.
|
|
||||||
|
|
||||||
Guards against the header being read from a hardcoded name in parallel with
|
|
||||||
the setting.
|
|
||||||
"""
|
|
||||||
user = UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
|
||||||
|
|
||||||
response = client.get(
|
|
||||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|||||||
@@ -381,11 +381,38 @@ def test_api_rooms_update_administrators_of_another():
|
|||||||
assert other_room.slug == "old-name"
|
assert other_room.slug == "old-name"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException])
|
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
|
||||||
@patch.object(RoomManagement, "update_metadata")
|
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
|
||||||
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
"""Should not fail the API request when the LiveKit room does not exist yet."""
|
||||||
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
user = UserFactory()
|
||||||
mock_update_metadata.side_effect = exception
|
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."""
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
room = RoomFactory(
|
room = RoomFactory(
|
||||||
users=[(user, random.choice(["administrator", "owner"]))],
|
users=[(user, random.choice(["administrator", "owner"]))],
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ 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,
|
||||||
@@ -22,7 +20,7 @@ def test_delete_room_calls_livekit(mock_create_livekit_client):
|
|||||||
mock_api.aclose = mock.AsyncMock()
|
mock_api.aclose = mock.AsyncMock()
|
||||||
mock_create_livekit_client.return_value = mock_api
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
RoomManagement.delete_room("room-abc")
|
RoomManagement().delete_room("room-abc")
|
||||||
|
|
||||||
mock_api.room.delete_room.assert_awaited_once()
|
mock_api.room.delete_room.assert_awaited_once()
|
||||||
request = mock_api.room.delete_room.await_args.args[0]
|
request = mock_api.room.delete_room.await_args.args[0]
|
||||||
@@ -41,7 +39,7 @@ def test_delete_room_raises_not_found(mock_create_livekit_client):
|
|||||||
mock_create_livekit_client.return_value = mock_api
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(RoomNotFoundException):
|
with pytest.raises(RoomNotFoundException):
|
||||||
RoomManagement.delete_room("missing-room")
|
RoomManagement().delete_room("missing-room")
|
||||||
|
|
||||||
mock_api.aclose.assert_awaited_once()
|
mock_api.aclose.assert_awaited_once()
|
||||||
|
|
||||||
@@ -57,25 +55,6 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
|||||||
mock_create_livekit_client.return_value = mock_api
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(RoomManagementException):
|
with pytest.raises(RoomManagementException):
|
||||||
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},
|
|
||||||
)
|
|
||||||
|
|
||||||
RoomManagement.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,17 +16,8 @@ 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 (
|
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
||||||
Application,
|
|
||||||
ApplicationScope,
|
|
||||||
RoleChoices,
|
|
||||||
Room,
|
|
||||||
RoomAccessLevel,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from core.services.room_management import RoomManagement
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -889,509 +880,6 @@ 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
|
||||||
@@ -2009,106 +1497,6 @@ 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
|
||||||
# ==============================
|
# ==============================
|
||||||
@@ -2160,32 +1548,6 @@ 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)
|
||||||
|
|||||||
@@ -129,15 +129,6 @@ class Base(Configuration):
|
|||||||
MEDIA_BASE_URL = values.Value(
|
MEDIA_BASE_URL = values.Value(
|
||||||
"", environ_name="MEDIA_BASE_URL", environ_prefix=None
|
"", environ_name="MEDIA_BASE_URL", environ_prefix=None
|
||||||
)
|
)
|
||||||
# Header the reverse proxy uses to pass the original request URL to the
|
|
||||||
# media-auth subrequest views. nginx-ingress sends X-Original-URL, which is
|
|
||||||
# the default. Other proxies use different headers -- Traefik's ForwardAuth,
|
|
||||||
# for instance, sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
|
|
||||||
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
|
|
||||||
default="HTTP_X_ORIGINAL_URL",
|
|
||||||
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ RUN apk update && apk upgrade \
|
|||||||
musl \
|
musl \
|
||||||
musl-utils \
|
musl-utils \
|
||||||
zlib>=1.3.2-r0 \
|
zlib>=1.3.2-r0 \
|
||||||
libexpat>=2.8.4-r0 \
|
|
||||||
&& apk del curl
|
&& apk del curl
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Track } from 'livekit-client'
|
|||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
|
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
@@ -48,6 +49,8 @@ interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
|||||||
disableTileControls?: boolean
|
disableTileControls?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MOUSE_IDLE_TIME = 3000
|
||||||
|
|
||||||
export const ParticipantTile: (
|
export const ParticipantTile: (
|
||||||
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
||||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||||
@@ -89,6 +92,8 @@ export const ParticipantTile: (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
|
const isRemoteScreenShare =
|
||||||
|
isScreenShare && !trackReference.participant.isLocal
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||||
|
|
||||||
const participantColor = getParticipantColor(trackReference.participant)
|
const participantColor = getParticipantColor(trackReference.participant)
|
||||||
@@ -98,11 +103,38 @@ export const ParticipantTile: (
|
|||||||
})
|
})
|
||||||
const participantName = name || identity || 'Unknown'
|
const participantName = name || identity || 'Unknown'
|
||||||
|
|
||||||
|
// Hover + idle tracking for the focus overlay (pin, effects, mute buttons).
|
||||||
|
const [isTileHovered, setIsTileHovered] = React.useState(false)
|
||||||
|
const [isIdle, setIsIdle] = React.useState(false)
|
||||||
|
const idleTimerRef = React.useRef<number | null>(null)
|
||||||
|
|
||||||
|
const handleTileMouseMove = React.useCallback(() => {
|
||||||
|
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||||
|
idleTimerRef.current = window.setTimeout(
|
||||||
|
() => setIsIdle(true),
|
||||||
|
MOUSE_IDLE_TIME
|
||||||
|
)
|
||||||
|
setIsIdle(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isOverlayVisible = hasKeyboardFocus || (isTileHovered && !isIdle)
|
||||||
|
|
||||||
|
// tileRef: fullscreen target. setRefs merges it with the forwarded ref on the same node.
|
||||||
|
const tileRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const setRefs = React.useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||||
|
if (typeof ref === 'function') ref(node)
|
||||||
|
else if (ref)
|
||||||
|
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||||
|
},
|
||||||
|
[ref]
|
||||||
|
)
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
...elementProps,
|
...elementProps,
|
||||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
'aria-label': t('containerLabel', { name: participantName }),
|
'aria-label': t('containerLabel', { name: participantName }),
|
||||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
@@ -120,8 +152,57 @@ export const ParticipantTile: (
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isVideoTrack =
|
||||||
|
isTrackReference(trackReference) &&
|
||||||
|
(trackReference.publication?.kind === 'video' ||
|
||||||
|
trackReference.source === Track.Source.Camera ||
|
||||||
|
trackReference.source === Track.Source.ScreenShare)
|
||||||
|
|
||||||
|
let trackMedia: React.ReactNode = null
|
||||||
|
if (isVideoTrack) {
|
||||||
|
if (isRemoteScreenShare) {
|
||||||
|
trackMedia = (
|
||||||
|
<ScreenShareZoomableVideo
|
||||||
|
trackRef={trackReference}
|
||||||
|
tileRef={tileRef}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
manageSubscription={autoManageSubscription}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
trackMedia = (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
manageSubscription={autoManageSubscription}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (isTrackReference(trackReference)) {
|
||||||
|
trackMedia = (
|
||||||
|
<AudioTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
<div
|
||||||
|
ref={setRefs}
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
{...interactiveProps}
|
||||||
|
onMouseEnter={() => setIsTileHovered(true)}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setIsTileHovered(false)
|
||||||
|
setIsIdle(false)
|
||||||
|
if (idleTimerRef.current) {
|
||||||
|
window.clearTimeout(idleTimerRef.current)
|
||||||
|
idleTimerRef.current = null
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseMove={handleTileMouseMove}
|
||||||
|
>
|
||||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
{trackReference.participant.isLocal && (
|
{trackReference.participant.isLocal && (
|
||||||
@@ -129,23 +210,7 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{isTrackReference(trackReference) &&
|
{trackMedia}
|
||||||
(trackReference.publication?.kind === 'video' ||
|
|
||||||
trackReference.source === Track.Source.Camera ||
|
|
||||||
trackReference.source === Track.Source.ScreenShare) ? (
|
|
||||||
<VideoTrack
|
|
||||||
trackRef={trackReference}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
manageSubscription={autoManageSubscription}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
isTrackReference(trackReference) && (
|
|
||||||
<AudioTrack
|
|
||||||
trackRef={trackReference}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
color={participantColor}
|
color={participantColor}
|
||||||
@@ -164,7 +229,7 @@ export const ParticipantTile: (
|
|||||||
{!disableMetadata && !disableTileControls && (
|
{!disableMetadata && !disableTileControls && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
hasKeyboardFocus={hasKeyboardFocus}
|
isVisible={isOverlayVisible}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
|
|||||||
+12
-40
@@ -1,44 +1,22 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { FocusButton } from './FocusButton'
|
import { FocusButton } from './FocusButton'
|
||||||
import { EffectsButton } from './EffectsButton'
|
import { EffectsButton } from './EffectsButton'
|
||||||
import { MuteButton } from './MuteButton'
|
import { MuteButton } from './MuteButton'
|
||||||
import { ZoomButton } from './ZoomButton'
|
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
|
||||||
|
|
||||||
type FadeOverlayProps = {
|
type FadeOverlayProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
hasKeyboardFocus: boolean
|
isVisible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
// Pointer-events none so this overlay doesn't block the zoom surface below.
|
||||||
const [active, setActive] = useState(false)
|
// Hover and idle tracking therefore lives on the tile, which still receives
|
||||||
const idleTimerRef = useRef<number | null>(null)
|
// the pointer events, and comes back in as isVisible.
|
||||||
|
const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
|
||||||
const clearIdleTimer = () => {
|
|
||||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const armIdleTimer = () => {
|
|
||||||
clearIdleTimer()
|
|
||||||
idleTimerRef.current = window.setTimeout(() => {
|
|
||||||
setActive(false)
|
|
||||||
}, MOUSE_IDLE_TIME)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleActivity = () => {
|
|
||||||
setActive(true)
|
|
||||||
armIdleTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => clearIdleTimer, [])
|
|
||||||
|
|
||||||
const isVisible = hasKeyboardFocus || active
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
@@ -50,15 +28,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
})}
|
})}
|
||||||
data-visible={isVisible || undefined}
|
data-visible={isVisible || undefined}
|
||||||
aria-hidden={!isVisible}
|
aria-hidden={!isVisible}
|
||||||
onMouseEnter={handleActivity}
|
|
||||||
onMouseMove={handleActivity}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
clearIdleTimer()
|
|
||||||
setActive(false)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isVisible && children}
|
{isVisible && children}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,10 +40,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
|||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
export const ParticipantTileFocus = ({
|
||||||
trackRef,
|
trackRef,
|
||||||
hasKeyboardFocus,
|
isVisible,
|
||||||
}: {
|
}: {
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
hasKeyboardFocus: boolean
|
isVisible: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const participant = trackRef.participant
|
const participant = trackRef.participant
|
||||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||||
@@ -78,7 +51,7 @@ export const ParticipantTileFocus = ({
|
|||||||
const canMute = useCanMute(participant)
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
<FadeOverlay isVisible={isVisible}>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50',
|
backgroundColor: 'primaryDark.50',
|
||||||
@@ -87,6 +60,7 @@ export const ParticipantTileFocus = ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||||
|
pointerEvents: 'auto',
|
||||||
_hover: {
|
_hover: {
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
},
|
},
|
||||||
@@ -94,7 +68,7 @@ export const ParticipantTileFocus = ({
|
|||||||
>
|
>
|
||||||
<HStack gap={0.5} padding={0.5}>
|
<HStack gap={0.5} padding={0.5}>
|
||||||
<FocusButton trackRef={trackRef} />
|
<FocusButton trackRef={trackRef} />
|
||||||
{!isScreenShare ? (
|
{!isScreenShare && (
|
||||||
<>
|
<>
|
||||||
{isLocal ? (
|
{isLocal ? (
|
||||||
<EffectsButton />
|
<EffectsButton />
|
||||||
@@ -102,8 +76,6 @@ export const ParticipantTileFocus = ({
|
|||||||
canMute && <MuteButton participant={participant} />
|
canMute && <MuteButton participant={participant} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiFullscreenLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const ZoomButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
|
||||||
trackRef,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isFullscreenAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('fullScreen')}
|
|
||||||
onPress={() => toggleFullScreen()}
|
|
||||||
>
|
|
||||||
<RiFullscreenLine />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { VideoTrack } from '@livekit/components-react'
|
||||||
|
import { type TrackReference } from '@livekit/components-core'
|
||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
interface ScreenShareVideoTrackProps {
|
||||||
|
trackRef: TrackReference
|
||||||
|
onSubscriptionStatusChanged: (subscribed: boolean) => void
|
||||||
|
manageSubscription?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom/pan updates the wrapper transform only; skip VideoTrack re-renders.
|
||||||
|
export const ScreenShareVideoTrack = memo(
|
||||||
|
({
|
||||||
|
trackRef,
|
||||||
|
onSubscriptionStatusChanged,
|
||||||
|
manageSubscription,
|
||||||
|
}: ScreenShareVideoTrackProps) => (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={trackRef}
|
||||||
|
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
|
||||||
|
manageSubscription={manageSubscription}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ScreenShareVideoTrack.displayName = 'ScreenShareVideoTrack'
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import {
|
||||||
|
RiCollapseDiagonalLine,
|
||||||
|
RiExpandDiagonalLine,
|
||||||
|
RiFullscreenExitLine,
|
||||||
|
RiZoomInLine,
|
||||||
|
RiZoomOutLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Toolbar } from 'react-aria-components'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { isMacintosh } from '@/utils/livekit'
|
||||||
|
import { srOnly } from '@/styles/a11y'
|
||||||
|
|
||||||
|
interface ScreenShareZoomControlsProps {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
isZoomed: boolean
|
||||||
|
zoomPercentage: number
|
||||||
|
canZoomIn: boolean
|
||||||
|
canZoomOut: boolean
|
||||||
|
onZoomIn: () => void
|
||||||
|
onZoomOut: () => void
|
||||||
|
onResetZoom: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScreenShareZoomControls = ({
|
||||||
|
containerRef,
|
||||||
|
isZoomed,
|
||||||
|
zoomPercentage,
|
||||||
|
canZoomIn,
|
||||||
|
canZoomOut,
|
||||||
|
onZoomIn,
|
||||||
|
onZoomOut,
|
||||||
|
onResetZoom,
|
||||||
|
}: ScreenShareZoomControlsProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const hadFocusInCollapsibleRef = useRef(false)
|
||||||
|
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
// Tracks whether this tile's container triggered fullscreen (vs another share's).
|
||||||
|
const wasThisTileFullscreen = useRef(false)
|
||||||
|
const isFullscreenAvailable = document.fullscreenEnabled
|
||||||
|
|
||||||
|
// Covers Esc and browser UI exits, not just the toolbar button.
|
||||||
|
// Only this tile's instance announces to avoid duplicates with multiple shares.
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => {
|
||||||
|
const isThisTileFullscreen =
|
||||||
|
document.fullscreenElement === containerRef.current
|
||||||
|
setIsFullscreen(isThisTileFullscreen)
|
||||||
|
|
||||||
|
if (isThisTileFullscreen) {
|
||||||
|
wasThisTileFullscreen.current = true
|
||||||
|
announce(t('fullScreenEntered'), 'assertive')
|
||||||
|
} else if (wasThisTileFullscreen.current) {
|
||||||
|
wasThisTileFullscreen.current = false
|
||||||
|
announce(t('fullScreenExited'), 'assertive')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', onChange)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||||
|
}, [announce, t, containerRef])
|
||||||
|
|
||||||
|
// Back at 100 % the collapsible controls are disabled and hidden, which drops
|
||||||
|
// keyboard focus on the body. Hand it to the zoom in button instead, the only
|
||||||
|
// control of that group still reachable.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isZoomed || !hadFocusInCollapsibleRef.current) return
|
||||||
|
hadFocusInCollapsibleRef.current = false
|
||||||
|
zoomInButtonRef.current?.focus()
|
||||||
|
}, [isZoomed])
|
||||||
|
|
||||||
|
const toggleFullScreen = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement === containerRef.current) {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
} else {
|
||||||
|
// Tile container so zoom controls stay visible in fullscreen.
|
||||||
|
await containerRef.current?.requestFullscreen()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling fullscreen:', error)
|
||||||
|
}
|
||||||
|
}, [containerRef])
|
||||||
|
|
||||||
|
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '12px',
|
||||||
|
right: '12px',
|
||||||
|
zIndex: 2,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* react-aria Toolbar: left/right arrows move between the controls and
|
||||||
|
Tab leaves the group as a whole, as the toolbar role implies. */}
|
||||||
|
<Toolbar
|
||||||
|
aria-label={t('toolbarLabel')}
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
borderRadius: '2rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
opacity: 0.7,
|
||||||
|
transition: 'opacity 200ms linear',
|
||||||
|
_hover: {
|
||||||
|
opacity: 0.95,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span className={srOnly}>
|
||||||
|
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
|
||||||
|
</span>
|
||||||
|
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
|
||||||
|
trick keeps overflow:hidden from clipping focus rings. */}
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
|
||||||
|
padding: '3px',
|
||||||
|
margin: '-3px',
|
||||||
|
})}
|
||||||
|
style={{
|
||||||
|
maxWidth: isZoomed ? '12rem' : '0',
|
||||||
|
opacity: isZoomed ? 1 : 0,
|
||||||
|
}}
|
||||||
|
aria-hidden={!isZoomed}
|
||||||
|
onFocus={() => {
|
||||||
|
hadFocusInCollapsibleRef.current = true
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// Disabling a focused button blurs it with no relatedTarget, so the
|
||||||
|
// flag must survive that case for the effect above to rescue focus.
|
||||||
|
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('fitToWindow')}
|
||||||
|
aria-label={t('fitToWindow')}
|
||||||
|
isDisabled={!isZoomed}
|
||||||
|
onPress={onResetZoom}
|
||||||
|
>
|
||||||
|
<RiFullscreenExitLine size={20} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('zoomOutWithShortcut', {
|
||||||
|
shortcut: wheelShortcutVisual,
|
||||||
|
})}
|
||||||
|
aria-label={t('zoomOut')}
|
||||||
|
isDisabled={!isZoomed || !canZoomOut}
|
||||||
|
onPress={onZoomOut}
|
||||||
|
>
|
||||||
|
<RiZoomOutLine size={20} />
|
||||||
|
</Button>
|
||||||
|
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={css({
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: '3.25rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
userSelect: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '0 0.25rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{zoomPercentage} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
ref={zoomInButtonRef}
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcutVisual })}
|
||||||
|
aria-label={t('zoomIn')}
|
||||||
|
isDisabled={!canZoomIn}
|
||||||
|
onPress={onZoomIn}
|
||||||
|
>
|
||||||
|
<RiZoomInLine size={20} />
|
||||||
|
</Button>
|
||||||
|
{isFullscreenAvailable && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
||||||
|
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
||||||
|
onPress={toggleFullScreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<RiCollapseDiagonalLine size={20} />
|
||||||
|
) : (
|
||||||
|
<RiExpandDiagonalLine size={20} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { type TrackReference } from '@livekit/components-core'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
|
||||||
|
import { ScreenShareVideoTrack } from './ScreenShareVideoTrack'
|
||||||
|
|
||||||
|
interface ScreenShareZoomableVideoProps {
|
||||||
|
trackRef: TrackReference
|
||||||
|
tileRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
onSubscriptionStatusChanged: (subscribed: boolean) => void
|
||||||
|
manageSubscription?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScreenShareZoomableVideo = ({
|
||||||
|
trackRef,
|
||||||
|
tileRef,
|
||||||
|
onSubscriptionStatusChanged,
|
||||||
|
manageSubscription,
|
||||||
|
}: ScreenShareZoomableVideoProps) => {
|
||||||
|
const zoom = useScreenShareZoom()
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
// SR announcement: announce zoom level on change, with a one-time pan hint
|
||||||
|
// on the first zoom above 100 % per session.
|
||||||
|
const prevZoomRef = useRef(zoom.zoomPercentage)
|
||||||
|
const hasAnnouncedPanHint = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevZoomRef.current === zoom.zoomPercentage) return
|
||||||
|
const wasAtDefault = prevZoomRef.current <= 100
|
||||||
|
prevZoomRef.current = zoom.zoomPercentage
|
||||||
|
|
||||||
|
if (wasAtDefault && zoom.isZoomed && !hasAnnouncedPanHint.current) {
|
||||||
|
hasAnnouncedPanHint.current = true
|
||||||
|
announce(t('panHint', { level: zoom.zoomPercentage }), 'polite')
|
||||||
|
} else {
|
||||||
|
announce(t('currentZoomLevel', { level: zoom.zoomPercentage }), 'polite')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zoom.isZoomed) hasAnnouncedPanHint.current = false
|
||||||
|
}, [zoom.zoomPercentage, zoom.isZoomed, announce, t])
|
||||||
|
|
||||||
|
// Attach keyboard listener on the tile container (has tabIndex=0).
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tileRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener('keydown', zoom.handleKeyDown)
|
||||||
|
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
|
||||||
|
}, [tileRef, zoom.handleKeyDown])
|
||||||
|
|
||||||
|
// Native wheel listener with { passive: false } so preventDefault works.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = zoom.surfaceElRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
|
||||||
|
return () => el.removeEventListener('wheel', zoom.handleWheel)
|
||||||
|
}, [zoom.handleWheel, zoom.surfaceElRef])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={zoom.surfaceElRef}
|
||||||
|
className={css({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
userSelect: 'none',
|
||||||
|
// Leaves the browser's native pinch-zoom available on touch devices
|
||||||
|
// while still routing single-pointer drags to useMove for panning.
|
||||||
|
touchAction: 'pinch-zoom',
|
||||||
|
})}
|
||||||
|
{...zoom.moveProps}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={zoom.transformElRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScreenShareVideoTrack
|
||||||
|
trackRef={trackRef}
|
||||||
|
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
|
||||||
|
manageSubscription={manageSubscription}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScreenShareZoomControls
|
||||||
|
containerRef={tileRef}
|
||||||
|
isZoomed={zoom.isZoomed}
|
||||||
|
zoomPercentage={zoom.zoomPercentage}
|
||||||
|
canZoomIn={zoom.canZoomIn}
|
||||||
|
canZoomOut={zoom.canZoomOut}
|
||||||
|
onZoomIn={zoom.zoomIn}
|
||||||
|
onZoomOut={zoom.zoomOut}
|
||||||
|
onResetZoom={zoom.resetZoom}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { useCallback, useRef, useSyncExternalStore } from 'react'
|
||||||
|
import { useMove } from 'react-aria'
|
||||||
|
import type { MoveMoveEvent } from '@react-types/shared'
|
||||||
|
import {
|
||||||
|
FULL_PICTURE_RATIO,
|
||||||
|
MIN_ZOOM,
|
||||||
|
PAN_STEP,
|
||||||
|
WHEEL_ZOOM_SPEED,
|
||||||
|
ZOOM_STEP,
|
||||||
|
type PanOffset,
|
||||||
|
type ZoomSnapshot,
|
||||||
|
buildZoomSnapshot,
|
||||||
|
clampPan,
|
||||||
|
clampZoom,
|
||||||
|
getCursorFromZoomState,
|
||||||
|
getCursorPercentsFromWheelEvent,
|
||||||
|
getPanDeltaPercentsFromMove,
|
||||||
|
getPictureRatio,
|
||||||
|
getWheelPanOffset,
|
||||||
|
getZoomTransform,
|
||||||
|
} from '../utils/screenShareZoom'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages zoom and pan state for a remote screen share.
|
||||||
|
*
|
||||||
|
* Performance: zoom/pan live in refs and are applied imperatively to the DOM
|
||||||
|
* (via transformElRef / surfaceElRef) so the hot path (drag, wheel) never
|
||||||
|
* triggers a React re-render. A useSyncExternalStore snapshot is flushed only
|
||||||
|
* when the toolbar UI needs to update (zoom level change, drag end).
|
||||||
|
*
|
||||||
|
* Drag/touch panning is handled by react-aria's useMove (moveProps).
|
||||||
|
* Ctrl/Cmd + wheel zoom is a native listener (must be non-passive to
|
||||||
|
* preventDefault and block browser page zoom).
|
||||||
|
* Arrow key panning and +/-/0 zoom are on a keydown listener attached to the
|
||||||
|
* tile container (which has tabIndex=0 and focus).
|
||||||
|
*/
|
||||||
|
export const useScreenShareZoom = () => {
|
||||||
|
const zoomRef = useRef(MIN_ZOOM)
|
||||||
|
const panRef = useRef<PanOffset>({ x: 0, y: 0 })
|
||||||
|
const draggingRef = useRef(false)
|
||||||
|
|
||||||
|
// The consumer binds these to the inner transform div and the outer drag surface.
|
||||||
|
const transformElRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const surfaceElRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
// Snapshot store: subscribers are notified only on explicit flush() calls.
|
||||||
|
const snapshotRef = useRef<ZoomSnapshot>(
|
||||||
|
buildZoomSnapshot(MIN_ZOOM, { x: 0, y: 0 }, false)
|
||||||
|
)
|
||||||
|
const listenersRef = useRef(new Set<() => void>())
|
||||||
|
|
||||||
|
const subscribe = useCallback((cb: () => void) => {
|
||||||
|
listenersRef.current.add(cb)
|
||||||
|
return () => {
|
||||||
|
listenersRef.current.delete(cb)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getSnapshot = useCallback(() => snapshotRef.current, [])
|
||||||
|
|
||||||
|
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||||
|
|
||||||
|
const flush = useCallback(() => {
|
||||||
|
snapshotRef.current = buildZoomSnapshot(
|
||||||
|
zoomRef.current,
|
||||||
|
panRef.current,
|
||||||
|
draggingRef.current
|
||||||
|
)
|
||||||
|
listenersRef.current.forEach((cb) => cb())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const applyTransform = useCallback(() => {
|
||||||
|
const el = transformElRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.transform = getZoomTransform(zoomRef.current, panRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// The video is letterboxed inside the surface by object-fit: contain, so the
|
||||||
|
// pan bounds depend on how much of the surface the picture actually covers.
|
||||||
|
// Read live rather than cached: both the tile and the shared resolution can
|
||||||
|
// change at any time.
|
||||||
|
const readPictureRatio = useCallback(() => {
|
||||||
|
const surface = surfaceElRef.current
|
||||||
|
const video = transformElRef.current?.querySelector('video')
|
||||||
|
if (!surface || !video) return FULL_PICTURE_RATIO
|
||||||
|
return getPictureRatio(
|
||||||
|
surface.clientWidth,
|
||||||
|
surface.clientHeight,
|
||||||
|
video.videoWidth,
|
||||||
|
video.videoHeight
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const applyCursor = useCallback(() => {
|
||||||
|
const el = surfaceElRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.cursor = getCursorFromZoomState(
|
||||||
|
zoomRef.current,
|
||||||
|
draggingRef.current
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const zoomIn = useCallback(() => {
|
||||||
|
const next = clampZoom(zoomRef.current + ZOOM_STEP)
|
||||||
|
zoomRef.current = next
|
||||||
|
panRef.current = clampPan(panRef.current, next, readPictureRatio())
|
||||||
|
applyTransform()
|
||||||
|
applyCursor()
|
||||||
|
flush()
|
||||||
|
}, [applyTransform, applyCursor, flush, readPictureRatio])
|
||||||
|
|
||||||
|
const zoomOut = useCallback(() => {
|
||||||
|
const next = clampZoom(zoomRef.current - ZOOM_STEP)
|
||||||
|
zoomRef.current = next
|
||||||
|
panRef.current =
|
||||||
|
next <= MIN_ZOOM
|
||||||
|
? { x: 0, y: 0 }
|
||||||
|
: clampPan(panRef.current, next, readPictureRatio())
|
||||||
|
applyTransform()
|
||||||
|
applyCursor()
|
||||||
|
flush()
|
||||||
|
}, [applyTransform, applyCursor, flush, readPictureRatio])
|
||||||
|
|
||||||
|
const resetZoom = useCallback(() => {
|
||||||
|
zoomRef.current = MIN_ZOOM
|
||||||
|
panRef.current = { x: 0, y: 0 }
|
||||||
|
applyTransform()
|
||||||
|
applyCursor()
|
||||||
|
flush()
|
||||||
|
}, [applyTransform, applyCursor, flush])
|
||||||
|
|
||||||
|
// Must be attached with { passive: false } so preventDefault() blocks
|
||||||
|
// the browser's native Ctrl+scroll page zoom.
|
||||||
|
const handleWheel = useCallback(
|
||||||
|
(e: WheelEvent) => {
|
||||||
|
if (!e.ctrlKey && !e.metaKey) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const target = e.currentTarget as HTMLElement
|
||||||
|
const prev = zoomRef.current
|
||||||
|
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
|
||||||
|
const next = clampZoom(prev + delta)
|
||||||
|
|
||||||
|
if (next <= MIN_ZOOM) {
|
||||||
|
zoomRef.current = MIN_ZOOM
|
||||||
|
panRef.current = { x: 0, y: 0 }
|
||||||
|
} else {
|
||||||
|
const { cursorXPercent, cursorYPercent } =
|
||||||
|
getCursorPercentsFromWheelEvent(e, target)
|
||||||
|
zoomRef.current = next
|
||||||
|
panRef.current = getWheelPanOffset({
|
||||||
|
pan: panRef.current,
|
||||||
|
prevZoom: prev,
|
||||||
|
nextZoom: next,
|
||||||
|
cursorXPercent,
|
||||||
|
cursorYPercent,
|
||||||
|
ratio: readPictureRatio(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTransform()
|
||||||
|
applyCursor()
|
||||||
|
flush()
|
||||||
|
},
|
||||||
|
[applyTransform, applyCursor, flush, readPictureRatio]
|
||||||
|
)
|
||||||
|
|
||||||
|
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
|
||||||
|
// here because moveProps is on the zoom surface, while focus is on the tile
|
||||||
|
// container, see handleKeyDown below.
|
||||||
|
const { moveProps } = useMove({
|
||||||
|
onMoveStart() {
|
||||||
|
if (zoomRef.current <= MIN_ZOOM) return
|
||||||
|
draggingRef.current = true
|
||||||
|
applyCursor()
|
||||||
|
flush()
|
||||||
|
},
|
||||||
|
onMove(e: MoveMoveEvent) {
|
||||||
|
if (zoomRef.current <= MIN_ZOOM) return
|
||||||
|
|
||||||
|
const el = surfaceElRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
|
||||||
|
e.deltaX,
|
||||||
|
e.deltaY,
|
||||||
|
el
|
||||||
|
)
|
||||||
|
|
||||||
|
panRef.current = clampPan(
|
||||||
|
{
|
||||||
|
x: panRef.current.x + deltaXPercent,
|
||||||
|
y: panRef.current.y + deltaYPercent,
|
||||||
|
},
|
||||||
|
zoomRef.current,
|
||||||
|
readPictureRatio()
|
||||||
|
)
|
||||||
|
|
||||||
|
applyTransform()
|
||||||
|
// Mouse drag: skip flush (imperative-only) to avoid re-renders per frame.
|
||||||
|
// Keyboard: flush so the toolbar reflects the updated position.
|
||||||
|
if (e.pointerType === 'keyboard') {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMoveEnd() {
|
||||||
|
draggingRef.current = false
|
||||||
|
applyTransform()
|
||||||
|
applyCursor()
|
||||||
|
flush()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const panBy = useCallback(
|
||||||
|
(dx: number, dy: number) => {
|
||||||
|
panRef.current = clampPan(
|
||||||
|
{ x: panRef.current.x + dx, y: panRef.current.y + dy },
|
||||||
|
zoomRef.current,
|
||||||
|
readPictureRatio()
|
||||||
|
)
|
||||||
|
applyTransform()
|
||||||
|
flush()
|
||||||
|
},
|
||||||
|
[applyTransform, flush, readPictureRatio]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attached to the tile container (not the zoom surface) where keyboard
|
||||||
|
// focus lives. Arrows pan, +/-/0 zoom.
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
const isZoomed = zoomRef.current > MIN_ZOOM
|
||||||
|
if (!isZoomed && e.key !== '+' && e.key !== '=') return
|
||||||
|
|
||||||
|
if (e.key.startsWith('Arrow') && e.target !== e.currentTarget) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(PAN_STEP, 0)
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(-PAN_STEP, 0)
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(0, PAN_STEP)
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(0, -PAN_STEP)
|
||||||
|
break
|
||||||
|
case '+':
|
||||||
|
case '=':
|
||||||
|
e.preventDefault()
|
||||||
|
zoomIn()
|
||||||
|
break
|
||||||
|
case '-':
|
||||||
|
e.preventDefault()
|
||||||
|
zoomOut()
|
||||||
|
break
|
||||||
|
case '0':
|
||||||
|
e.preventDefault()
|
||||||
|
resetZoom()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[panBy, zoomIn, zoomOut, resetZoom]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
transformElRef,
|
||||||
|
surfaceElRef,
|
||||||
|
moveProps,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
resetZoom,
|
||||||
|
handleWheel,
|
||||||
|
handleKeyDown,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
export const MIN_ZOOM = 1
|
||||||
|
export const MAX_ZOOM = 4
|
||||||
|
export const ZOOM_STEP = 0.1
|
||||||
|
export const WHEEL_ZOOM_SPEED = 0.002
|
||||||
|
export const PAN_STEP = 5
|
||||||
|
|
||||||
|
// Half of a 100 % axis. Geometry, not a tunable: it is both the centre the
|
||||||
|
// cursor offset is measured from and the half extent the pan is clamped
|
||||||
|
// against, so the two stay consistent by construction.
|
||||||
|
export const HALF_EXTENT_PERCENT = 50
|
||||||
|
|
||||||
|
export interface PanOffset {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fraction of the surface each axis of the picture covers, in [0, 1].
|
||||||
|
export interface PictureRatio {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FULL_PICTURE_RATIO: PictureRatio = { x: 1, y: 1 }
|
||||||
|
|
||||||
|
export interface ZoomSnapshot {
|
||||||
|
zoomLevel: number
|
||||||
|
zoomPercentage: number
|
||||||
|
panOffset: PanOffset
|
||||||
|
isZoomed: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
canZoomIn: boolean
|
||||||
|
canZoomOut: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clampZoom = (value: number) => {
|
||||||
|
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restrict pan so the picture always covers the view. Pan is a % of the
|
||||||
|
// surface, in which object-fit: contain letterboxes the picture: its half
|
||||||
|
// extent is `ratio * 50` against a view half extent of 50, and scaling by
|
||||||
|
// `zoom` must keep `zoom * (ratio * 50 - |pan|) >= 50`. An axis whose picture
|
||||||
|
// is still smaller than the view is pinned to 0, keeping the bars symmetric.
|
||||||
|
export const clampPan = (
|
||||||
|
pan: PanOffset,
|
||||||
|
zoom: number,
|
||||||
|
ratio: PictureRatio
|
||||||
|
): PanOffset => {
|
||||||
|
const maxPanX = Math.max(0, (ratio.x - 1 / zoom) * HALF_EXTENT_PERCENT)
|
||||||
|
const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * HALF_EXTENT_PERCENT)
|
||||||
|
return {
|
||||||
|
x: Math.max(-maxPanX, Math.min(maxPanX, pan.x)),
|
||||||
|
y: Math.max(-maxPanY, Math.min(maxPanY, pan.y)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-axis fraction of the surface covered by an object-fit: contain picture.
|
||||||
|
export const getPictureRatio = (
|
||||||
|
surfaceWidth: number,
|
||||||
|
surfaceHeight: number,
|
||||||
|
videoWidth: number,
|
||||||
|
videoHeight: number
|
||||||
|
): PictureRatio => {
|
||||||
|
if (!surfaceWidth || !surfaceHeight || !videoWidth || !videoHeight) {
|
||||||
|
return FULL_PICTURE_RATIO
|
||||||
|
}
|
||||||
|
const surfaceRatio = surfaceWidth / surfaceHeight
|
||||||
|
const videoRatio = videoWidth / videoHeight
|
||||||
|
return surfaceRatio > videoRatio
|
||||||
|
? { x: videoRatio / surfaceRatio, y: 1 }
|
||||||
|
: { x: 1, y: surfaceRatio / videoRatio }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildZoomSnapshot = (
|
||||||
|
zoom: number,
|
||||||
|
pan: PanOffset,
|
||||||
|
dragging: boolean
|
||||||
|
): ZoomSnapshot => {
|
||||||
|
return {
|
||||||
|
zoomLevel: zoom,
|
||||||
|
zoomPercentage: Math.round(zoom * 100),
|
||||||
|
panOffset: pan,
|
||||||
|
isZoomed: zoom > MIN_ZOOM,
|
||||||
|
isDragging: dragging,
|
||||||
|
canZoomIn: zoom < MAX_ZOOM,
|
||||||
|
canZoomOut: zoom > MIN_ZOOM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getZoomTransform = (zoom: number, pan: PanOffset) => {
|
||||||
|
return `scale(${zoom}) translate(${pan.x}%, ${pan.y}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCursorFromZoomState = (zoom: number, dragging: boolean) => {
|
||||||
|
if (zoom <= MIN_ZOOM) return 'default'
|
||||||
|
return dragging ? 'grabbing' : 'grab'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the content point under the cursor anchored while zooming. With
|
||||||
|
// `scale(z) translate(pan%)`, a point at `offset` from the center renders at
|
||||||
|
// `z * (offset + pan)`, so holding it still gives:
|
||||||
|
// pan' = pan + cursor * (1 / zoom' - 1 / zoom).
|
||||||
|
export const getWheelPanOffset = ({
|
||||||
|
pan,
|
||||||
|
prevZoom,
|
||||||
|
nextZoom,
|
||||||
|
cursorXPercent,
|
||||||
|
cursorYPercent,
|
||||||
|
ratio,
|
||||||
|
}: {
|
||||||
|
pan: PanOffset
|
||||||
|
prevZoom: number
|
||||||
|
nextZoom: number
|
||||||
|
cursorXPercent: number
|
||||||
|
cursorYPercent: number
|
||||||
|
ratio: PictureRatio
|
||||||
|
}): PanOffset => {
|
||||||
|
const panShift = 1 / nextZoom - 1 / prevZoom
|
||||||
|
return clampPan(
|
||||||
|
{
|
||||||
|
x: pan.x + cursorXPercent * panShift,
|
||||||
|
y: pan.y + cursorYPercent * panShift,
|
||||||
|
},
|
||||||
|
nextZoom,
|
||||||
|
ratio
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert cursor pixel position to a % offset from the surface center.
|
||||||
|
export const getCursorPercentsFromWheelEvent = (
|
||||||
|
e: WheelEvent,
|
||||||
|
target: HTMLElement
|
||||||
|
) => {
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
cursorXPercent:
|
||||||
|
((e.clientX - rect.left) / rect.width) * 100 - HALF_EXTENT_PERCENT,
|
||||||
|
cursorYPercent:
|
||||||
|
((e.clientY - rect.top) / rect.height) * 100 - HALF_EXTENT_PERCENT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert useMove pixel deltas to % of the surface dimensions.
|
||||||
|
export const getPanDeltaPercentsFromMove = (
|
||||||
|
deltaX: number,
|
||||||
|
deltaY: number,
|
||||||
|
surface: HTMLElement
|
||||||
|
) => {
|
||||||
|
const rect = surface.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
deltaXPercent: (deltaX / rect.width) * 100,
|
||||||
|
deltaYPercent: (deltaY / rect.height) * 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
saveVideoPublishResolution,
|
saveVideoPublishResolution,
|
||||||
saveVideoSubscribeQuality,
|
saveVideoSubscribeQuality,
|
||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
VIDEO_RESOLUTIONS,
|
|
||||||
VideoResolution,
|
VideoResolution,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
import { RowWrapper } from './layout/RowWrapper'
|
import { RowWrapper } from './layout/RowWrapper'
|
||||||
@@ -71,7 +70,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVideoResolutionChange = async (key: VideoResolution) => {
|
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
|
||||||
saveVideoPublishResolution(key)
|
saveVideoPublishResolution(key)
|
||||||
const videoTrack = localParticipant.getTrackPublication(
|
const videoTrack = localParticipant.getTrackPublication(
|
||||||
Track.Source.Camera
|
Track.Source.Camera
|
||||||
@@ -125,13 +124,20 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
}, [videoDeviceId, videoElement])
|
}, [videoDeviceId, videoElement])
|
||||||
|
|
||||||
const resolutionItems = useMemo(() => {
|
const resolutionItems = useMemo(() => {
|
||||||
const labels: Record<VideoResolution, string> = {
|
return [
|
||||||
h1080: `${t('resolution.publish.items.veryHigh')} (1080p)`,
|
{
|
||||||
h720: `${t('resolution.publish.items.high')} (720p)`,
|
value: 'h720',
|
||||||
h360: `${t('resolution.publish.items.medium')} (360p)`,
|
label: `${t('resolution.publish.items.high')} (720p)`,
|
||||||
h180: `${t('resolution.publish.items.low')} (180p)`,
|
},
|
||||||
}
|
{
|
||||||
return VIDEO_RESOLUTIONS.map((value) => ({ value, label: labels[value] }))
|
value: 'h360',
|
||||||
|
label: `${t('resolution.publish.items.medium')} (360p)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'h180',
|
||||||
|
label: `${t('resolution.publish.items.low')} (180p)`,
|
||||||
|
},
|
||||||
|
]
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
const videoQualityItems = useMemo(() => {
|
const videoQualityItems = useMemo(() => {
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export type ShortcutId =
|
|||||||
| 'recording'
|
| 'recording'
|
||||||
| 'reaction'
|
| 'reaction'
|
||||||
| 'fullscreen'
|
| 'fullscreen'
|
||||||
|
| 'zoom-in'
|
||||||
|
| 'zoom-out'
|
||||||
|
| 'zoom-reset'
|
||||||
|
| 'zoom-pan'
|
||||||
|
|
||||||
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
||||||
shortcutCatalog.find((item) => item.id === id)
|
shortcutCatalog.find((item) => item.id === id)
|
||||||
@@ -24,7 +28,7 @@ export type ShortcutDescriptor = {
|
|||||||
id: ShortcutId
|
id: ShortcutId
|
||||||
category: ShortcutCategory
|
category: ShortcutCategory
|
||||||
shortcut?: Shortcut
|
shortcut?: Shortcut
|
||||||
kind?: 'press' | 'longPress'
|
kind?: 'press' | 'longPress' | 'arrows'
|
||||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
@@ -86,4 +90,27 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
|
|||||||
category: 'interaction',
|
category: 'interaction',
|
||||||
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
||||||
},
|
},
|
||||||
|
// Screen share zoom keys are unmodified, so they are bound on the focused
|
||||||
|
// tile instead of being registered globally. They are listed here so the
|
||||||
|
// shortcuts panel stays exhaustive.
|
||||||
|
{
|
||||||
|
id: 'zoom-in',
|
||||||
|
category: 'interaction',
|
||||||
|
shortcut: { key: '+' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zoom-out',
|
||||||
|
category: 'interaction',
|
||||||
|
shortcut: { key: '-' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zoom-reset',
|
||||||
|
category: 'interaction',
|
||||||
|
shortcut: { key: '0' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zoom-pan',
|
||||||
|
category: 'interaction',
|
||||||
|
kind: 'arrows',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel,
|
shiftLabel,
|
||||||
plusLabel,
|
plusLabel,
|
||||||
noShortcutLabel,
|
noShortcutLabel,
|
||||||
|
keyLabels,
|
||||||
}: {
|
}: {
|
||||||
controlLabel: string
|
controlLabel: string
|
||||||
commandLabel: string
|
commandLabel: string
|
||||||
@@ -33,10 +34,12 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel: string
|
shiftLabel: string
|
||||||
plusLabel: string
|
plusLabel: string
|
||||||
noShortcutLabel: string
|
noShortcutLabel: string
|
||||||
|
// Spelled-out names for keys screen readers may skip or mispronounce.
|
||||||
|
keyLabels?: Record<string, string>
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!shortcut) return noShortcutLabel
|
if (!shortcut) return noShortcutLabel
|
||||||
const key = shortcut.key?.toUpperCase()
|
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase()
|
||||||
if (!key) return noShortcutLabel
|
if (!key) return noShortcutLabel
|
||||||
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
||||||
const altWord = isMacintosh() ? optionLabel : altLabel
|
const altWord = isMacintosh() ? optionLabel : altLabel
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export const useShortcutFormatting = () => {
|
|||||||
|
|
||||||
const formatVisual = useCallback(
|
const formatVisual = useCallback(
|
||||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||||
|
if (kind === 'arrows') {
|
||||||
|
return t('shortcutsPanel.visual.arrows')
|
||||||
|
}
|
||||||
if (code && kind === 'longPress') {
|
if (code && kind === 'longPress') {
|
||||||
const label = getKeyLabelFromCode(code)
|
const label = getKeyLabelFromCode(code)
|
||||||
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
||||||
@@ -23,6 +26,9 @@ export const useShortcutFormatting = () => {
|
|||||||
|
|
||||||
const formatForSR = useCallback(
|
const formatForSR = useCallback(
|
||||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||||
|
if (kind === 'arrows') {
|
||||||
|
return t('shortcutsPanel.sr.arrows')
|
||||||
|
}
|
||||||
if (code && kind === 'longPress') {
|
if (code && kind === 'longPress') {
|
||||||
const label = getKeyLabelFromCode(code)
|
const label = getKeyLabelFromCode(code)
|
||||||
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
||||||
@@ -35,6 +41,10 @@ export const useShortcutFormatting = () => {
|
|||||||
shiftLabel: t('shortcutsPanel.sr.shift'),
|
shiftLabel: t('shortcutsPanel.sr.shift'),
|
||||||
plusLabel: t('shortcutsPanel.sr.plus'),
|
plusLabel: t('shortcutsPanel.sr.plus'),
|
||||||
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
||||||
|
keyLabels: {
|
||||||
|
'+': t('shortcutsPanel.sr.plusKey'),
|
||||||
|
'-': t('shortcutsPanel.sr.minusKey'),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[t]
|
[t]
|
||||||
|
|||||||
@@ -756,6 +756,24 @@
|
|||||||
"muteParticipant": "{{name}} stummschalten",
|
"muteParticipant": "{{name}} stummschalten",
|
||||||
"fullScreen": "Vollbild"
|
"fullScreen": "Vollbild"
|
||||||
},
|
},
|
||||||
|
"screenShareZoom": {
|
||||||
|
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
|
||||||
|
"zoomIn": "Vergrößern",
|
||||||
|
"zoomInWithShortcut": "Vergrößern ({{shortcut}})",
|
||||||
|
"zoomOut": "Verkleinern",
|
||||||
|
"zoomOutWithShortcut": "Verkleinern ({{shortcut}})",
|
||||||
|
"wheelShortcut": "Steuerung plus Mausrad",
|
||||||
|
"wheelShortcutMac": "Befehl plus Mausrad",
|
||||||
|
"wheelShortcutHint": "Tastenkürzel: Steuerung plus Mausrad zum Vergrößern oder Verkleinern.",
|
||||||
|
"wheelShortcutHintMac": "Tastenkürzel: Befehl plus Mausrad zum Vergrößern oder Verkleinern.",
|
||||||
|
"fitToWindow": "An Fenster anpassen",
|
||||||
|
"fullScreen": "Vollbild",
|
||||||
|
"exitFullScreen": "Vollbild beenden",
|
||||||
|
"currentZoomLevel": "Zoom {{level}} %",
|
||||||
|
"panHint": "Zoom {{level}} %. Mit der Maus ziehen oder den Fokus zurück auf die Bildschirmfreigabe setzen und mit den Pfeiltasten im Bild navigieren.",
|
||||||
|
"fullScreenEntered": "Vollbild aktiviert",
|
||||||
|
"fullScreenExited": "Vollbild deaktiviert"
|
||||||
|
},
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Tastenkürzel",
|
"title": "Tastenkürzel",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -775,7 +793,11 @@
|
|||||||
"raise-hand": "Hand heben oder senken",
|
"raise-hand": "Hand heben oder senken",
|
||||||
"toggle-chat": "Chat anzeigen/ausblenden",
|
"toggle-chat": "Chat anzeigen/ausblenden",
|
||||||
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
|
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
|
||||||
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
|
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen",
|
||||||
|
"zoom-in": "In einen geteilten Bildschirm hineinzoomen",
|
||||||
|
"zoom-out": "Aus einem geteilten Bildschirm herauszoomen",
|
||||||
|
"zoom-reset": "Zoom des geteilten Bildschirms zurücksetzen",
|
||||||
|
"zoom-pan": "Im gezoomten geteilten Bildschirm navigieren"
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Steuerung",
|
"control": "Steuerung",
|
||||||
@@ -785,10 +807,14 @@
|
|||||||
"shift": "Umschalt",
|
"shift": "Umschalt",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Halte {{key}} gedrückt",
|
"hold": "Halte {{key}} gedrückt",
|
||||||
|
"arrows": "Pfeiltasten",
|
||||||
|
"plusKey": "Plus-Taste",
|
||||||
|
"minusKey": "Minus-Taste",
|
||||||
"noShortcut": "Kein Tastenkürzel"
|
"noShortcut": "Kein Tastenkürzel"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Halte {{key}} gedrückt"
|
"hold": "Halte {{key}} gedrückt",
|
||||||
|
"arrows": "↑ ↓ ← →"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"label": "Wähle die maximale Auflösung beim Senden",
|
"label": "Wähle die maximale Auflösung beim Senden",
|
||||||
"items": {
|
"items": {
|
||||||
"veryHigh": "Sehr hohe Auflösung",
|
|
||||||
"high": "Hohe Auflösung",
|
"high": "Hohe Auflösung",
|
||||||
"medium": "Mittlere Auflösung",
|
"medium": "Mittlere Auflösung",
|
||||||
"low": "Niedrige Auflösung"
|
"low": "Niedrige Auflösung"
|
||||||
|
|||||||
@@ -756,6 +756,24 @@
|
|||||||
"muteParticipant": "Mute {{name}}",
|
"muteParticipant": "Mute {{name}}",
|
||||||
"fullScreen": "Full screen"
|
"fullScreen": "Full screen"
|
||||||
},
|
},
|
||||||
|
"screenShareZoom": {
|
||||||
|
"toolbarLabel": "Screen share zoom controls",
|
||||||
|
"zoomIn": "Zoom in",
|
||||||
|
"zoomInWithShortcut": "Zoom in ({{shortcut}})",
|
||||||
|
"zoomOut": "Zoom out",
|
||||||
|
"zoomOutWithShortcut": "Zoom out ({{shortcut}})",
|
||||||
|
"wheelShortcut": "Control plus scroll wheel",
|
||||||
|
"wheelShortcutMac": "Command plus scroll wheel",
|
||||||
|
"wheelShortcutHint": "Shortcut: Control plus scroll wheel to zoom in or out.",
|
||||||
|
"wheelShortcutHintMac": "Shortcut: Command plus scroll wheel to zoom in or out.",
|
||||||
|
"fitToWindow": "Fit to window",
|
||||||
|
"fullScreen": "Full screen",
|
||||||
|
"exitFullScreen": "Exit full screen",
|
||||||
|
"currentZoomLevel": "Zoom {{level}} %",
|
||||||
|
"panHint": "Zoom {{level}} %. Drag with mouse, or move focus back to the screen share and use arrow keys to navigate the picture.",
|
||||||
|
"fullScreenEntered": "Full screen enabled",
|
||||||
|
"fullScreenExited": "Full screen disabled"
|
||||||
|
},
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Keyboard shortcuts",
|
"title": "Keyboard shortcuts",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -775,7 +793,11 @@
|
|||||||
"raise-hand": "Raise or lower hand",
|
"raise-hand": "Raise or lower hand",
|
||||||
"toggle-chat": "Toggle chat",
|
"toggle-chat": "Toggle chat",
|
||||||
"toggle-participants": "Toggle participants",
|
"toggle-participants": "Toggle participants",
|
||||||
"open-shortcuts-settings": "Open shortcuts settings"
|
"open-shortcuts-settings": "Open shortcuts settings",
|
||||||
|
"zoom-in": "Zoom in on a shared screen",
|
||||||
|
"zoom-out": "Zoom out on a shared screen",
|
||||||
|
"zoom-reset": "Reset the shared screen zoom",
|
||||||
|
"zoom-pan": "Move around a zoomed shared screen"
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Control",
|
"control": "Control",
|
||||||
@@ -785,10 +807,14 @@
|
|||||||
"shift": "Shift",
|
"shift": "Shift",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Hold {{key}}",
|
"hold": "Hold {{key}}",
|
||||||
|
"arrows": "Arrow keys",
|
||||||
|
"plusKey": "Plus key",
|
||||||
|
"minusKey": "Minus key",
|
||||||
"noShortcut": "No shortcut"
|
"noShortcut": "No shortcut"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Hold {{key}}"
|
"hold": "Hold {{key}}",
|
||||||
|
"arrows": "↑ ↓ ← →"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"label": "Select your sending resolution (max.)",
|
"label": "Select your sending resolution (max.)",
|
||||||
"items": {
|
"items": {
|
||||||
"veryHigh": "Very high definition",
|
|
||||||
"high": "High definition",
|
"high": "High definition",
|
||||||
"medium": "Standard definition",
|
"medium": "Standard definition",
|
||||||
"low": "Low definition"
|
"low": "Low definition"
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"label": "Selecciona tu resolución de envío (máx.)",
|
"label": "Selecciona tu resolución de envío (máx.)",
|
||||||
"items": {
|
"items": {
|
||||||
"veryHigh": "Muy alta definición",
|
|
||||||
"high": "Alta definición",
|
"high": "Alta definición",
|
||||||
"medium": "Definición estándar",
|
"medium": "Definición estándar",
|
||||||
"low": "Baja definición"
|
"low": "Baja definición"
|
||||||
|
|||||||
@@ -756,6 +756,24 @@
|
|||||||
"muteParticipant": "Couper le micro de {{name}}",
|
"muteParticipant": "Couper le micro de {{name}}",
|
||||||
"fullScreen": "Plein écran"
|
"fullScreen": "Plein écran"
|
||||||
},
|
},
|
||||||
|
"screenShareZoom": {
|
||||||
|
"toolbarLabel": "Contrôles de zoom du partage d'écran",
|
||||||
|
"zoomIn": "Zoomer",
|
||||||
|
"zoomInWithShortcut": "Zoomer ({{shortcut}})",
|
||||||
|
"zoomOut": "Dézoomer",
|
||||||
|
"zoomOutWithShortcut": "Dézoomer ({{shortcut}})",
|
||||||
|
"wheelShortcut": "Contrôle plus molette",
|
||||||
|
"wheelShortcutMac": "Commande plus molette",
|
||||||
|
"wheelShortcutHint": "Raccourci : Contrôle plus molette pour zoomer ou dézoomer.",
|
||||||
|
"wheelShortcutHintMac": "Raccourci : Commande plus molette pour zoomer ou dézoomer.",
|
||||||
|
"fitToWindow": "Ajuster à la fenêtre",
|
||||||
|
"fullScreen": "Plein écran",
|
||||||
|
"exitFullScreen": "Quitter le plein écran",
|
||||||
|
"currentZoomLevel": "Zoom {{level}} %",
|
||||||
|
"panHint": "Zoom {{level}} %. Glissez avec la souris, ou revenez sur le partage d'écran et utilisez les touches fléchées pour naviguer dans l'image.",
|
||||||
|
"fullScreenEntered": "Plein écran activé",
|
||||||
|
"fullScreenExited": "Plein écran désactivé"
|
||||||
|
},
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Raccourcis clavier",
|
"title": "Raccourcis clavier",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -775,7 +793,11 @@
|
|||||||
"raise-hand": "Lever ou baisser la main",
|
"raise-hand": "Lever ou baisser la main",
|
||||||
"toggle-chat": "Afficher/Masquer le chat",
|
"toggle-chat": "Afficher/Masquer le chat",
|
||||||
"toggle-participants": "Afficher/Masquer les participants",
|
"toggle-participants": "Afficher/Masquer les participants",
|
||||||
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
|
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis",
|
||||||
|
"zoom-in": "Zoomer sur un écran partagé",
|
||||||
|
"zoom-out": "Dézoomer sur un écran partagé",
|
||||||
|
"zoom-reset": "Réinitialiser le zoom de l’écran partagé",
|
||||||
|
"zoom-pan": "Se déplacer dans un écran partagé zoomé"
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Contrôle",
|
"control": "Contrôle",
|
||||||
@@ -785,10 +807,14 @@
|
|||||||
"shift": "Majuscule",
|
"shift": "Majuscule",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Maintenir {{key}}",
|
"hold": "Maintenir {{key}}",
|
||||||
|
"arrows": "Touches fléchées",
|
||||||
|
"plusKey": "Touche plus",
|
||||||
|
"minusKey": "Touche moins",
|
||||||
"noShortcut": "Aucun raccourci"
|
"noShortcut": "Aucun raccourci"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Maintenir {{key}}"
|
"hold": "Maintenir {{key}}",
|
||||||
|
"arrows": "↑ ↓ ← →"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"label": "Sélectionner votre résolution d'envoi (max.)",
|
"label": "Sélectionner votre résolution d'envoi (max.)",
|
||||||
"items": {
|
"items": {
|
||||||
"veryHigh": "Très haute définition",
|
|
||||||
"high": "Haute définition",
|
"high": "Haute définition",
|
||||||
"medium": "Définition standard",
|
"medium": "Définition standard",
|
||||||
"low": "Basse définition"
|
"low": "Basse définition"
|
||||||
|
|||||||
@@ -756,6 +756,24 @@
|
|||||||
"muteParticipant": "Demp {{name}}",
|
"muteParticipant": "Demp {{name}}",
|
||||||
"fullScreen": "Volledig scherm"
|
"fullScreen": "Volledig scherm"
|
||||||
},
|
},
|
||||||
|
"screenShareZoom": {
|
||||||
|
"toolbarLabel": "Zoombediening voor schermdeling",
|
||||||
|
"zoomIn": "Inzoomen",
|
||||||
|
"zoomInWithShortcut": "Inzoomen ({{shortcut}})",
|
||||||
|
"zoomOut": "Uitzoomen",
|
||||||
|
"zoomOutWithShortcut": "Uitzoomen ({{shortcut}})",
|
||||||
|
"wheelShortcut": "Control plus scrollwiel",
|
||||||
|
"wheelShortcutMac": "Command plus scrollwiel",
|
||||||
|
"wheelShortcutHint": "Sneltoets: Control plus scrollwiel om in of uit te zoomen.",
|
||||||
|
"wheelShortcutHintMac": "Sneltoets: Command plus scrollwiel om in of uit te zoomen.",
|
||||||
|
"fitToWindow": "Aanpassen aan venster",
|
||||||
|
"fullScreen": "Volledig scherm",
|
||||||
|
"exitFullScreen": "Volledig scherm verlaten",
|
||||||
|
"currentZoomLevel": "Zoom {{level}} %",
|
||||||
|
"panHint": "Zoom {{level}} %. Sleep met de muis, of zet de focus terug op de schermdeling en gebruik de pijltjestoetsen om door het beeld te navigeren.",
|
||||||
|
"fullScreenEntered": "Volledig scherm ingeschakeld",
|
||||||
|
"fullScreenExited": "Volledig scherm uitgeschakeld"
|
||||||
|
},
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Sneltoetsen",
|
"title": "Sneltoetsen",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -775,7 +793,11 @@
|
|||||||
"raise-hand": "Hand opsteken of laten zakken",
|
"raise-hand": "Hand opsteken of laten zakken",
|
||||||
"toggle-chat": "Chat tonen/verbergen",
|
"toggle-chat": "Chat tonen/verbergen",
|
||||||
"toggle-participants": "Deelnemers tonen/verbergen",
|
"toggle-participants": "Deelnemers tonen/verbergen",
|
||||||
"open-shortcuts-settings": "Sneltoets-instellingen openen"
|
"open-shortcuts-settings": "Sneltoets-instellingen openen",
|
||||||
|
"zoom-in": "Inzoomen op een gedeeld scherm",
|
||||||
|
"zoom-out": "Uitzoomen op een gedeeld scherm",
|
||||||
|
"zoom-reset": "Zoom van het gedeelde scherm herstellen",
|
||||||
|
"zoom-pan": "Navigeren in een ingezoomd gedeeld scherm"
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Control",
|
"control": "Control",
|
||||||
@@ -785,10 +807,14 @@
|
|||||||
"shift": "Shift",
|
"shift": "Shift",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Houd {{key}} ingedrukt",
|
"hold": "Houd {{key}} ingedrukt",
|
||||||
|
"arrows": "Pijltoetsen",
|
||||||
|
"plusKey": "Plus-toets",
|
||||||
|
"minusKey": "Min-toets",
|
||||||
"noShortcut": "Geen sneltoets"
|
"noShortcut": "Geen sneltoets"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Houd {{key}} ingedrukt"
|
"hold": "Houd {{key}} ingedrukt",
|
||||||
|
"arrows": "↑ ↓ ← →"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"publish": {
|
"publish": {
|
||||||
"label": "Selecteer uw verzendresolutie (max.)",
|
"label": "Selecteer uw verzendresolutie (max.)",
|
||||||
"items": {
|
"items": {
|
||||||
"veryHigh": "Zeer hoge definitie",
|
|
||||||
"high": "Hoge definitie",
|
"high": "Hoge definitie",
|
||||||
"medium": "Standaarddefinitie",
|
"medium": "Standaarddefinitie",
|
||||||
"low": "Lage definitie"
|
"low": "Lage definitie"
|
||||||
|
|||||||
@@ -10,12 +10,7 @@ import {
|
|||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { VideoQuality } from 'livekit-client'
|
import { VideoQuality } from 'livekit-client'
|
||||||
|
|
||||||
export const VIDEO_RESOLUTIONS = ['h1080', 'h720', 'h360', 'h180'] as const
|
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||||
|
|
||||||
export type VideoResolution = (typeof VIDEO_RESOLUTIONS)[number]
|
|
||||||
|
|
||||||
const isVideoResolution = (value: unknown): value is VideoResolution =>
|
|
||||||
VIDEO_RESOLUTIONS.includes(value as VideoResolution)
|
|
||||||
|
|
||||||
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
|
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
|
||||||
processorConfig?: ProcessorConfig
|
processorConfig?: ProcessorConfig
|
||||||
@@ -26,17 +21,13 @@ export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getUserChoicesState(): LocalUserChoices {
|
function getUserChoicesState(): LocalUserChoices {
|
||||||
const stored: LocalUserChoices = {
|
return {
|
||||||
noiseReductionEnabled: false,
|
noiseReductionEnabled: false,
|
||||||
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
|
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
|
||||||
videoPublishResolution: 'h720',
|
videoPublishResolution: 'h720',
|
||||||
videoSubscribeQuality: VideoQuality.HIGH,
|
videoSubscribeQuality: VideoQuality.HIGH,
|
||||||
...loadUserChoices(),
|
...loadUserChoices(),
|
||||||
}
|
}
|
||||||
if (!isVideoResolution(stored.videoPublishResolution)) {
|
|
||||||
stored.videoPublishResolution = 'h720'
|
|
||||||
}
|
|
||||||
return stored
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
||||||
|
|||||||
Reference in New Issue
Block a user