mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-11 15:50:47 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 82fcd9f9f3 | |||
| 86d97b427c | |||
| b0bb5bc61b | |||
| 3773e43d93 |
+4
-1
@@ -11,7 +11,6 @@ and this project adheres to
|
||||
### Added
|
||||
|
||||
- ✨(backend) introduce a token exchange endpoint for iframe embeds
|
||||
- ✨(all) allow any authenticated user to start a recording
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -19,6 +18,7 @@ and this project adheres to
|
||||
- ⚡️(backend) reduce domain queries on the application token endpoint
|
||||
- ♻️️️(backend) use a dedicated auth scheme for LiveKit token auth
|
||||
- ♻️(all) stop relying on cookies for the lobby flow
|
||||
- ♿️(frontend) show a visible focus outline on menu items #1797
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -26,6 +26,9 @@ and this project adheres to
|
||||
- 🔒️(frontend) upgrade tiff to fix CVE-2026-4775
|
||||
- 🔒️(backend) bind accepted lobby entries to the current username
|
||||
- 🔒️(backend) sign lobby participant id
|
||||
- 🐛(frontend) omit the default speaker id sent to LiveKit
|
||||
- 🐛(frontend) play the speaker test when no output is picked
|
||||
- 🐛(frontend) promote the active speaker in the carousel layout
|
||||
|
||||
## [1.34.0] - 2026-10-07
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
|
||||
|
||||
The feature allows users to record their room sessions. When a recording is complete, the user who started it and the room's owners and administrators receive a notification with a link to download the recorded file. The transcript summary is sent to the user who started the recording. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
|
||||
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
|
||||
|
||||
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
||||
|
||||
@@ -89,7 +89,6 @@ sequenceDiagram
|
||||
| Option | Type | Default | Description |
|
||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
|
||||
| **RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED**| Boolean | `True` | When enabled, any authenticated participant currently connected to a room with the `trusted` or `public` access level can start and stop recordings. When disabled, only room admins and owners can. On `restricted` rooms, only admins and owners can record. |
|
||||
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
|
||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
|
||||
@@ -408,7 +408,6 @@ These are the environmental options available on meet backend.
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED | Let any authenticated participant connected to a trusted or public room start/stop recordings. Set to false to reserve recording to room admins/owners | true |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||
|
||||
@@ -44,9 +44,6 @@ def get_frontend_configuration(request):
|
||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||
"authenticated_participants_enabled": (
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED
|
||||
),
|
||||
},
|
||||
"background_image": {
|
||||
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
|
||||
|
||||
@@ -187,29 +187,29 @@ class IsPresentInMeeting(permissions.BasePermission):
|
||||
return False
|
||||
|
||||
|
||||
class ParticipantCapability(permissions.BasePermission):
|
||||
"""Base permission for in-meeting capabilities opened up to participants.
|
||||
class CanManageLobby(permissions.BasePermission):
|
||||
"""Grant lobby management (list/accept/deny waiting participants).
|
||||
|
||||
- Room admins/owners always hold the capability.
|
||||
- When the room access level is TRUSTED or PUBLIC, any
|
||||
session-authenticated user who is currently connected to the meeting
|
||||
holds it too.
|
||||
- Room admins/owners can always manage the lobby.
|
||||
- When the room access level is TRUSTED, any authenticated user who is
|
||||
currently connected to the meeting can manage the lobby. Presence is
|
||||
verified cache-first (Redis), falling back to the LiveKit API.
|
||||
|
||||
Access level is always read fresh from the DB; only presence is cached,
|
||||
so changing the room access level takes effect immediately.
|
||||
so changing the room to RESTRICTED takes effect immediately.
|
||||
"""
|
||||
|
||||
message = "You are not allowed to perform this action."
|
||||
enabled_setting = None
|
||||
message = "You are not allowed to manage this room's lobby."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check privileges first, then the participant presence path."""
|
||||
# pylint: disable=too-many-return-statements
|
||||
def has_object_permission(self, request, view, obj): # noqa: PLR0911
|
||||
"""Check privileges first, then the trusted-room presence path."""
|
||||
user = request.user
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Product choice: these capabilities are reserved for session-authenticated
|
||||
# Product choice: lobby management is reserved for session-authenticated
|
||||
# users with a real account, not holders of a LiveKit room token.
|
||||
if request.auth and hasattr(request.auth, "video"):
|
||||
return False
|
||||
@@ -217,35 +217,16 @@ class ParticipantCapability(permissions.BasePermission):
|
||||
if obj.is_administrator_or_owner(user):
|
||||
return True
|
||||
|
||||
is_open_room = obj.access_level in (
|
||||
RoomAccessLevel.TRUSTED,
|
||||
RoomAccessLevel.PUBLIC,
|
||||
)
|
||||
is_enabled = not self.enabled_setting or getattr(settings, self.enabled_setting)
|
||||
if not (is_open_room and is_enabled):
|
||||
if obj.access_level != RoomAccessLevel.TRUSTED:
|
||||
return False
|
||||
|
||||
self.message = "You must be connected to the meeting to manage its lobby."
|
||||
|
||||
try:
|
||||
return ParticipantsManagement().check_if_in_meeting_cached(
|
||||
room_name=str(obj.pk), identity=str(user.sub)
|
||||
)
|
||||
except (ParticipantNotFoundException, ParticipantsManagementException):
|
||||
except ParticipantNotFoundException:
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
|
||||
class CanManageLobby(ParticipantCapability):
|
||||
"""Grant lobby management (list/accept/deny waiting participants).
|
||||
|
||||
Public rooms have no lobby: the endpoints short-circuit there, so opening
|
||||
them to participants exposes nothing.
|
||||
"""
|
||||
|
||||
|
||||
class CanManageRecording(ParticipantCapability):
|
||||
"""Grant starting/stopping a room recording (screen recording or transcript).
|
||||
|
||||
Self-hosters can keep it admin/owner only with
|
||||
`RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED`.
|
||||
"""
|
||||
|
||||
enabled_setting = "RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED"
|
||||
|
||||
@@ -404,7 +404,7 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="start-recording",
|
||||
permission_classes=[
|
||||
permissions.CanManageRecording,
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
@@ -437,7 +437,11 @@ class RoomViewSet(
|
||||
mode=mode,
|
||||
options=options_data,
|
||||
)
|
||||
recording.grant_initial_accesses(starter=self.request.user)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
@@ -481,7 +485,7 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="stop-recording",
|
||||
permission_classes=[
|
||||
permissions.CanManageRecording,
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
|
||||
@@ -690,29 +690,6 @@ class Recording(BaseModel):
|
||||
"update": False,
|
||||
}
|
||||
|
||||
def grant_initial_accesses(self, starter):
|
||||
"""Make the starter and the room's owners/administrators OWNER of the recording.
|
||||
|
||||
The starter's access is created first, so it is the oldest one: the
|
||||
transcript summary, which goes to a single owner, is sent to them.
|
||||
|
||||
Room roles are copied when the recording starts: later changes to the
|
||||
room's accesses do not affect existing recordings.
|
||||
"""
|
||||
RecordingAccess.objects.create(
|
||||
user=starter, role=RoleChoices.OWNER, recording=self
|
||||
)
|
||||
|
||||
room_privileged_user_ids = (
|
||||
self.room.accesses.filter(role__in=[RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||
.exclude(user=starter)
|
||||
.values_list("user_id", flat=True)
|
||||
)
|
||||
RecordingAccess.objects.bulk_create(
|
||||
RecordingAccess(user_id=user_id, role=RoleChoices.OWNER, recording=self)
|
||||
for user_id in room_privileged_user_ids
|
||||
)
|
||||
|
||||
def is_savable(self) -> bool:
|
||||
"""Determine if the recording can be saved based on its current status."""
|
||||
|
||||
|
||||
@@ -222,23 +222,6 @@ class NotificationService:
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_summary_recipient(recording: models.Recording):
|
||||
"""Return the single user who receives the transcript summary.
|
||||
|
||||
A recording can have several owners (its starter and the room's
|
||||
owners/administrators), but the summary is generated once. It goes to
|
||||
the oldest owner, which is the starter: their access is created first
|
||||
(see `Recording.grant_initial_accesses`).
|
||||
"""
|
||||
oldest_owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(role=models.RoleChoices.OWNER, recording_id=recording.id)
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
return oldest_owner_access.user if oldest_owner_access else None
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
@@ -250,7 +233,14 @@ class NotificationService:
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
recipient = NotificationService._get_summary_recipient(recording)
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
@@ -260,7 +250,7 @@ class NotificationService:
|
||||
else:
|
||||
metadata_filename = None
|
||||
|
||||
if not recipient:
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
@@ -269,16 +259,16 @@ class NotificationService:
|
||||
)(recording.worker_id)
|
||||
|
||||
payload = {
|
||||
"owner_id": str(recipient.id),
|
||||
"owner_id": str(owner_access.user.id),
|
||||
"recording_filename": recording.key,
|
||||
"metadata_filename": metadata_filename,
|
||||
"email": recipient.email,
|
||||
"sub": recipient.sub,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
"room": recording.room.name,
|
||||
"language": recording.options.get("language"),
|
||||
"owner_timezone": str(recipient.timezone),
|
||||
"owner_timezone": str(owner_access.user.timezone),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"context_language": recipient.language,
|
||||
"context_language": owner_access.user.language,
|
||||
"recording_start_at": (started_at.isoformat() if started_at else None),
|
||||
"recording_end_at": (ended_at.isoformat() if ended_at else None),
|
||||
}
|
||||
@@ -318,7 +308,14 @@ class NotificationService:
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
recipient = NotificationService._get_summary_recipient(recording)
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
metadata_filename: None | str = None
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
@@ -326,7 +323,7 @@ class NotificationService:
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
|
||||
if not recipient:
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
@@ -353,8 +350,8 @@ class NotificationService:
|
||||
}
|
||||
|
||||
payload = {
|
||||
"user_sub": recipient.sub,
|
||||
"user_email": recipient.email,
|
||||
"user_sub": owner_access.user.sub,
|
||||
"user_email": owner_access.user.email,
|
||||
"cloud_storage_url": generate_download_s3_url(
|
||||
recording.key,
|
||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
@@ -363,20 +360,20 @@ class NotificationService:
|
||||
"language": recording.options.get(
|
||||
"language", get_language().split("-")[0].lower()
|
||||
),
|
||||
"context_language": recipient.language,
|
||||
"context_language": owner_access.user.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": recipient.email,
|
||||
"user_email": owner_access.user.email,
|
||||
"title": NotificationService._generate_title(
|
||||
locale=recipient.language
|
||||
locale=owner_access.user.language
|
||||
or recording.options.get("language", get_language()),
|
||||
room=recording.room.name,
|
||||
recording_datetime=started_at,
|
||||
owner_timezone=str(recipient.timezone),
|
||||
owner_timezone=str(owner_access.user.timezone),
|
||||
),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"form_link": form_link,
|
||||
"auto_create_summary": is_user_feature_flag_enabled(
|
||||
recipient, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
),
|
||||
},
|
||||
"metadata": metadata_payload,
|
||||
|
||||
@@ -479,24 +479,3 @@ def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
|
||||
assert isinstance(title, str)
|
||||
# ...so the payload serializes exactly the way ``requests`` serializes it.
|
||||
json.dumps(payload)
|
||||
|
||||
|
||||
def test_summary_recipient_is_oldest_owner():
|
||||
"""With several owners, the summary goes to the oldest one."""
|
||||
recording = factories.RecordingFactory()
|
||||
oldest = factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
for _ in range(2):
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
assert NotificationService._get_summary_recipient(recording) == oldest.user
|
||||
|
||||
|
||||
def test_summary_recipient_none_without_owner():
|
||||
"""No owner: nobody to send the summary to."""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
assert NotificationService._get_summary_recipient(recording) is None
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
"""Trusted and public rooms: any authenticated participant present in the meeting can record."""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from core.models import (
|
||||
Recording,
|
||||
RecordingStatusChoices,
|
||||
RoleChoices,
|
||||
RoomAccessLevel,
|
||||
)
|
||||
from core.recording.event.notification import NotificationService
|
||||
from core.services.presence import PresenceCache
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_manager():
|
||||
"""Mock the worker service factory and mediator."""
|
||||
with (
|
||||
mock.patch("core.api.viewsets.get_worker_service"),
|
||||
mock.patch("core.api.viewsets.WorkerServiceMediator") as mediator_class,
|
||||
):
|
||||
mediator = mock.Mock()
|
||||
mediator_class.return_value = mediator
|
||||
yield mediator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_recording(settings):
|
||||
"""Recording must be enabled for the endpoints to be reachable."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_open_room_present_user_can_start_recording(
|
||||
mock_check, access_level, mock_worker_manager
|
||||
):
|
||||
"""Authenticated + present in a trusted/public room -> 201, starter owns it."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=access_level)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
mock_check.assert_called_once_with(room_name=str(room.id), identity=str(user.sub))
|
||||
recording = Recording.objects.get()
|
||||
mock_worker_manager.start.assert_called_once_with(recording)
|
||||
access = recording.accesses.get()
|
||||
assert access.user == user
|
||||
assert access.role == RoleChoices.OWNER
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_presence_is_cached_until_participant_leaves(mock_check, mock_worker_manager):
|
||||
"""Presence is memoized like for the lobby; clearing it forces a LiveKit re-check."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = f"/api/v1.0/rooms/{room.id}/stop-recording/"
|
||||
|
||||
assert client.post(url).status_code == 200
|
||||
assert client.post(url).status_code == 200
|
||||
assert mock_check.call_count == 1
|
||||
|
||||
# participant_left webhook
|
||||
PresenceCache().clear(room.id, str(user.sub))
|
||||
mock_check.return_value = False
|
||||
|
||||
assert client.post(url).status_code == 403
|
||||
assert mock_check.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=False,
|
||||
)
|
||||
def test_open_room_absent_user_forbidden(mock_check, access_level, mock_worker_manager):
|
||||
"""Authenticated but not connected to the meeting -> 403."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "You are not allowed to perform this action."}
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_restricted_room_present_user_forbidden(mock_check, mock_worker_manager):
|
||||
"""Presence is not enough on restricted rooms; LiveKit is not even asked."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_check.assert_not_called()
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_open_room_present_user_can_stop_recording(
|
||||
mock_check, access_level, mock_worker_manager
|
||||
):
|
||||
"""Any present authenticated user can stop the active recording."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_worker_manager.stop.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_admin_does_not_need_presence(mock_check, mock_worker_manager):
|
||||
"""Admins/owners keep recording rights on any room, without a LiveKit check."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room.accesses.create(user=user, role=RoleChoices.ADMIN)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
def test_open_room_anonymous_forbidden(access_level):
|
||||
"""Anonymous users never record, even on public rooms."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_participant_path_disabled_by_setting(
|
||||
mock_check, access_level, settings, mock_worker_manager
|
||||
):
|
||||
"""Self-hosters can opt out: participants then get 403, LiveKit not asked."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||
room = RoomFactory(access_level=access_level)
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
start = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
stop = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert start.status_code == 403
|
||||
assert stop.status_code == 403
|
||||
mock_check.assert_not_called()
|
||||
mock_worker_manager.start.assert_not_called()
|
||||
mock_worker_manager.stop.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_participant_path_disabled_admin_still_records(
|
||||
mock_check, settings, mock_worker_manager
|
||||
):
|
||||
"""The setting only removes the participant path, never admin rights."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
room.accesses.create(user=user, role=RoleChoices.OWNER)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_lobby_not_affected_by_recording_setting(mock_check, settings):
|
||||
"""The recording opt-out must not leak into the lobby capability."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_public_room_lobby_is_open_but_empty(mock_check):
|
||||
"""Participants pass the lobby permission on public rooms, which have no lobby."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
listing = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
entry = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert listing.status_code == 200
|
||||
assert listing.json() == {"participants": []}
|
||||
assert entry.status_code == 404
|
||||
assert entry.json() == {"message": "Room has no lobby system."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
def test_config_exposes_authenticated_participants_setting(settings, enabled):
|
||||
"""The frontend reads the setting from /config/ to gate the UI."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = enabled
|
||||
|
||||
response = APIClient().get("/api/v1.0/config/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["recording"]["authenticated_participants_enabled"] is enabled
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_room_owners_and_admins_get_access_to_participant_recording(
|
||||
mock_check, mock_worker_manager
|
||||
):
|
||||
"""Starter and room owners/admins all own the recording; members get nothing."""
|
||||
starter = UserFactory()
|
||||
room_owner = UserFactory()
|
||||
room_admin = UserFactory()
|
||||
room_member = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
room.accesses.create(user=room_owner, role=RoleChoices.OWNER)
|
||||
room.accesses.create(user=room_admin, role=RoleChoices.ADMIN)
|
||||
room.accesses.create(user=room_member, role=RoleChoices.MEMBER)
|
||||
client = APIClient()
|
||||
client.force_login(starter)
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get()
|
||||
assert dict(recording.accesses.values_list("user_id", "role")) == {
|
||||
starter.id: RoleChoices.OWNER,
|
||||
room_owner.id: RoleChoices.OWNER,
|
||||
room_admin.id: RoleChoices.OWNER,
|
||||
}
|
||||
|
||||
# Room admins can see and download it; plain members cannot.
|
||||
client.force_login(room_admin)
|
||||
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200
|
||||
client.force_login(room_member)
|
||||
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 404
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_room_admin_starter_is_not_duplicated(mock_check, mock_worker_manager):
|
||||
"""A room admin who starts the recording gets a single access."""
|
||||
starter = UserFactory()
|
||||
co_owner = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room.accesses.create(user=starter, role=RoleChoices.ADMIN)
|
||||
room.accesses.create(user=co_owner, role=RoleChoices.OWNER)
|
||||
client = APIClient()
|
||||
client.force_login(starter)
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get()
|
||||
assert dict(recording.accesses.values_list("user_id", "role")) == {
|
||||
starter.id: RoleChoices.OWNER,
|
||||
co_owner.id: RoleChoices.OWNER,
|
||||
}
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_participant_can_stop_recording_started_by_someone_else(
|
||||
mock_check, mock_worker_manager
|
||||
):
|
||||
"""Stopping is room-wide on trusted rooms, not tied to who started it."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
recording.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_worker_manager.stop.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_summary_goes_to_starter_among_several_owners(mock_check, mock_worker_manager):
|
||||
"""The starter's access is created first, so the summary goes to them."""
|
||||
starter = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
for _ in range(2):
|
||||
room.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
|
||||
client = APIClient()
|
||||
client.force_login(starter)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "transcript"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get()
|
||||
assert recording.accesses.count() == 3
|
||||
assert NotificationService._get_summary_recipient(recording) == starter
|
||||
@@ -11,7 +11,7 @@ from livekit import api as livekit_api
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Recording, RoomAccessLevel
|
||||
from ...models import Recording
|
||||
from ...recording.worker.exceptions import RecordingStartError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -42,13 +42,8 @@ def mock_worker_manager(mock_worker_service):
|
||||
yield mock_mediator
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"recording_enabled, expected_status",
|
||||
[(True, 401), (False, 404)],
|
||||
)
|
||||
def test_start_recording_anonymous(settings, recording_enabled, expected_status):
|
||||
def test_start_recording_anonymous():
|
||||
"""Anonymous users should not be allowed to start room recordings."""
|
||||
settings.RECORDING_ENABLE = recording_enabled
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
@@ -57,14 +52,14 @@ def test_start_recording_anonymous(settings, recording_enabled, expected_status)
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == expected_status
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_non_owner_and_non_administrator(settings):
|
||||
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from ...models import Recording, RecordingStatusChoices, RoomAccessLevel
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.worker.exceptions import RecordingStopError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -41,20 +41,15 @@ def mock_worker_manager(mock_worker_service):
|
||||
yield mock_mediator
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"recording_enabled, expected_status",
|
||||
[(True, 401), (False, 404)],
|
||||
)
|
||||
def test_stop_recording_anonymous(settings, recording_enabled, expected_status):
|
||||
def test_stop_recording_anonymous():
|
||||
"""Anonymous users should not be allowed to stop room recordings."""
|
||||
settings.RECORDING_ENABLE = recording_enabled
|
||||
room = RoomFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == expected_status
|
||||
assert response.status_code == 401
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
@@ -62,7 +57,7 @@ def test_stop_recording_anonymous(settings, recording_enabled, expected_status):
|
||||
def test_stop_recording_non_owner_and_non_administrator(settings):
|
||||
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
client = APIClient()
|
||||
|
||||
@@ -793,14 +793,6 @@ class Base(Configuration):
|
||||
RECORDING_ENABLE = values.BooleanValue(
|
||||
False, environ_name="RECORDING_ENABLE", environ_prefix=None
|
||||
)
|
||||
# Let any authenticated participant connected to a trusted or public room
|
||||
# start/stop recordings. Disable to keep recording reserved to room
|
||||
# admins/owners.
|
||||
RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = values.BooleanValue(
|
||||
True,
|
||||
environ_name="RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_OUTPUT_FOLDER = values.Value(
|
||||
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface ApiConfig {
|
||||
available_modes?: RecordingMode[]
|
||||
expiration_days?: number
|
||||
max_duration?: number
|
||||
authenticated_participants_enabled?: boolean
|
||||
}
|
||||
background_image: {
|
||||
upload_is_enabled: boolean
|
||||
|
||||
@@ -3,6 +3,9 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||
import { useTopSpeaker } from '../hooks/useTopSpeaker'
|
||||
import { splitCarouselTiles } from '../utils/splitCarouselTiles'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
@@ -66,7 +69,19 @@ export function CarouselLayout({
|
||||
setPrevTiles(tilesThatFit)
|
||||
}
|
||||
|
||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||
const topSpeakerId = useTopSpeaker(tracks)
|
||||
const { head, rest, restSpan } = splitCarouselTiles(
|
||||
tracks,
|
||||
topSpeakerId,
|
||||
maxVisibleTiles
|
||||
)
|
||||
const stableRest = useVisualStableUpdate(rest, restSpan)
|
||||
const sortedTiles = [...head, ...stableRest]
|
||||
|
||||
useSpeakerPromotionTrigger(
|
||||
sortedTiles.slice(0, head.length + restSpan),
|
||||
tracks
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (asideEl.current) {
|
||||
|
||||
@@ -46,7 +46,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
|
||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks, tracks)
|
||||
|
||||
useSwipe(gridEl, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
|
||||
@@ -8,35 +8,42 @@ import { useEffect, useReducer, useRef } from 'react'
|
||||
* Tripwire for speaker promotion.
|
||||
*
|
||||
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
||||
* re-render of the host component only when an active speaker has none of
|
||||
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
||||
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
||||
* state and performs the actual swap.
|
||||
* re-render of the host when a speaker has a tile in `allTiles` but none in
|
||||
* `visibleTiles`. That render re-runs `useVisualStableUpdate`, which reads
|
||||
* live `participant.isSpeaking` state and performs the actual swap.
|
||||
*
|
||||
* Speakers already visible are ignored. Everything else costs zero React work.
|
||||
* Speakers already visible cost nothing, nor do speakers the layout doesn't
|
||||
* show at all (e.g. the pinned participant, rendered by the focus view).
|
||||
*
|
||||
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||
*/
|
||||
export function useSpeakerPromotionTrigger(
|
||||
sortedTiles: TrackReferenceOrPlaceholder[]
|
||||
/** Tiles inside the span the layout guarantees to speakers. */
|
||||
visibleTiles: TrackReferenceOrPlaceholder[],
|
||||
/** Every tile the layout shows, visible or not. */
|
||||
allTiles: TrackReferenceOrPlaceholder[]
|
||||
) {
|
||||
const room = useRoomContext()
|
||||
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
||||
|
||||
// Refs so the listener reads current values without re-subscribing
|
||||
// and without itself being a render dependency.
|
||||
const tilesRef = useRef(sortedTiles)
|
||||
tilesRef.current = sortedTiles
|
||||
// Read by the listener at event time, so it never re-subscribes.
|
||||
const tilesRef = useRef({ visibleTiles, allTiles })
|
||||
tilesRef.current = { visibleTiles, allTiles }
|
||||
|
||||
useEffect(() => {
|
||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||
const tiles = tilesRef.current
|
||||
const hiddenSpeakerExists = speakers.some(
|
||||
(speaker) =>
|
||||
!tiles.some((t) => t.participant.identity === speaker.identity)
|
||||
)
|
||||
if (hiddenSpeakerExists) {
|
||||
const { visibleTiles, allTiles } = tilesRef.current
|
||||
const hasTileIn =
|
||||
(tiles: TrackReferenceOrPlaceholder[]) => (identity: string) =>
|
||||
tiles.some((t) => t.participant.identity === identity)
|
||||
const isShown = hasTileIn(allTiles)
|
||||
const isVisible = hasTileIn(visibleTiles)
|
||||
|
||||
const isHidden = (identity: string) =>
|
||||
isShown(identity) && !isVisible(identity)
|
||||
|
||||
if (speakers.some(({ identity }) => isHidden(identity))) {
|
||||
forceRender()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { watchTopSpeaker } from '../utils/watchTopSpeaker'
|
||||
|
||||
/**
|
||||
* Identity of the participant who should hold the top speaker slot, among
|
||||
* those with a tile in `tracks`. See `watchTopSpeaker` for the rules.
|
||||
*
|
||||
* Re-renders the host only when the holder changes. Expects
|
||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||
*/
|
||||
export function useTopSpeaker(tracks: TrackReferenceOrPlaceholder[]) {
|
||||
const room = useRoomContext()
|
||||
const [topSpeakerId, setTopSpeakerId] = useState<string>()
|
||||
|
||||
// Read by the listener at event time; subscribing once keeps the hold
|
||||
// state alive across renders.
|
||||
const tracksRef = useRef(tracks)
|
||||
tracksRef.current = tracks
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
watchTopSpeaker(room, {
|
||||
isEligible: (identity) =>
|
||||
tracksRef.current.some((t) => t.participant.identity === identity),
|
||||
onChange: setTopSpeakerId,
|
||||
}),
|
||||
[room]
|
||||
)
|
||||
|
||||
return topSpeakerId
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
|
||||
/**
|
||||
* Splits carousel tiles into a fixed head and a rest that is free to reorder.
|
||||
*
|
||||
* Head: the local camera, then the top speaker's camera. The rest goes through
|
||||
* `useVisualStableUpdate`, which only keeps speakers visible within its first
|
||||
* `restSpan` tiles. The head's slots are taken out of that span, so the head
|
||||
* can never push a promoted speaker out of view.
|
||||
*
|
||||
* The speaker slot is reserved even while it is empty: `useVisualStableUpdate`
|
||||
* treats any change of span as a relayout and re-sorts everything, so the span
|
||||
* must not move when the first speaker gets promoted.
|
||||
*/
|
||||
export function splitCarouselTiles(
|
||||
tracks: TrackReferenceOrPlaceholder[],
|
||||
topSpeakerId: string | undefined,
|
||||
maxVisibleTiles: number
|
||||
) {
|
||||
const cameraOf = (match: (t: TrackReferenceOrPlaceholder) => boolean) =>
|
||||
tracks.find((t) => t.source === Track.Source.Camera && match(t))
|
||||
|
||||
const local = cameraOf((t) => t.participant.isLocal)
|
||||
const speaker = topSpeakerId
|
||||
? cameraOf((t) => t.participant.identity === topSpeakerId)
|
||||
: undefined
|
||||
|
||||
const head = [local, speaker].filter((t) => t !== undefined)
|
||||
const rest = tracks.filter((t) => !head.includes(t))
|
||||
|
||||
const reservedSlots = (local ? 1 : 0) + 1
|
||||
const restSpan = Math.max(maxVisibleTiles - reservedSlots, 1)
|
||||
|
||||
return { head, rest, restSpan }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import type { Room } from 'livekit-client'
|
||||
|
||||
/** Minimum time a speaker keeps the top slot before someone else can take it. */
|
||||
export const TOP_SPEAKER_HOLD_MS = 2000
|
||||
|
||||
type SpeakerSource = Pick<Room, 'on' | 'off' | 'activeSpeakers'>
|
||||
|
||||
/**
|
||||
* Calls `onChange` with the participant who should hold the top speaker slot.
|
||||
*
|
||||
* The candidate is the loudest remote speaker for whom `isEligible` is true
|
||||
* (e.g. who has a tile in the layout). The slot is sticky: silence keeps the
|
||||
* current holder. A new speaker only takes over once the holder has had it for
|
||||
* `holdMs`, and only if they are still the loudest at that point; someone who
|
||||
* stops talking before then never steals it. A holder who is no longer
|
||||
* eligible (left, got pinned) is replaced right away.
|
||||
*
|
||||
* `onChange` only fires on an actual change. Returns an unsubscribe function.
|
||||
*/
|
||||
export function watchTopSpeaker(
|
||||
room: SpeakerSource,
|
||||
{
|
||||
isEligible,
|
||||
onChange,
|
||||
holdMs = TOP_SPEAKER_HOLD_MS,
|
||||
}: {
|
||||
isEligible: (identity: string) => boolean
|
||||
onChange: (identity: string) => void
|
||||
holdMs?: number
|
||||
}
|
||||
) {
|
||||
let holder: string | undefined
|
||||
let holdUntil = 0
|
||||
let retry: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const evaluate = () => {
|
||||
clearTimeout(retry)
|
||||
|
||||
// `activeSpeakers` is sorted loudest first and is always current, which
|
||||
// the deferred retry needs; the event payload would be stale by then.
|
||||
const candidate = room.activeSpeakers.find(
|
||||
(p) => !p.isLocal && isEligible(p.identity)
|
||||
)?.identity
|
||||
if (!candidate || candidate === holder) return
|
||||
|
||||
const wait = holder && isEligible(holder) ? holdUntil - Date.now() : 0
|
||||
if (wait > 0) {
|
||||
retry = setTimeout(evaluate, wait)
|
||||
return
|
||||
}
|
||||
|
||||
holder = candidate
|
||||
holdUntil = Date.now() + holdMs
|
||||
onChange(candidate)
|
||||
}
|
||||
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, evaluate)
|
||||
return () => {
|
||||
room.off(RoomEvent.ActiveSpeakersChanged, evaluate)
|
||||
clearTimeout(retry)
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,6 @@ import { useTranscriptionLanguage } from '@/features/settings'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import {
|
||||
useCanRecord,
|
||||
useIsRecordingOpenToParticipants,
|
||||
} from '../hooks/useCanRecord'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { LimitDescription } from './LimitDescription'
|
||||
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||
@@ -43,8 +39,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const canRecord = useCanRecord()
|
||||
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
@@ -122,13 +116,11 @@ export const ScreenRecordingSidePanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!canRecord) {
|
||||
if (!isAdminOrOwner) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
i18nKey={
|
||||
isRecordingOpenToParticipants ? 'notLoggedIn' : 'notAdminOrOwner'
|
||||
}
|
||||
i18nKey="notAdminOrOwner"
|
||||
helpArticle={data?.support?.help_article_recording}
|
||||
imagePath="/assets/intro-slider/4.png"
|
||||
handleRequest={handleRequestScreenRecording}
|
||||
|
||||
@@ -6,8 +6,7 @@ import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
useHasFeatureWithoutRecordingRights,
|
||||
useIsRecordingOpenToParticipants,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
useRecordingStatuses,
|
||||
} from '../index'
|
||||
import { useState } from 'react'
|
||||
@@ -58,13 +57,12 @@ export const TranscriptSidePanel = () => {
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
const hasFeatureWithoutRecordingRights = useHasFeatureWithoutRecordingRights(
|
||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
|
||||
|
||||
const isMetadataCollectorEnabled = useIsMetadataCollectorEnabled()
|
||||
|
||||
@@ -141,13 +139,11 @@ export const TranscriptSidePanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFeatureWithoutRecordingRights) {
|
||||
if (hasFeatureWithoutAdminRights) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
i18nKey={
|
||||
isRecordingOpenToParticipants ? 'notLoggedIn' : 'notAdminOrOwner'
|
||||
}
|
||||
i18nKey="notAdminOrOwner"
|
||||
helpArticle={data?.support?.help_article_transcript}
|
||||
imagePath="/assets/intro-slider/3.png"
|
||||
handleRequest={handleRequestTranscription}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
|
||||
// Room access levels on which authenticated participants can record.
|
||||
const OPEN_ACCESS_LEVELS = [ApiAccessLevel.TRUSTED, ApiAccessLevel.PUBLIC]
|
||||
|
||||
export const useIsRecordingOpenToParticipants = () => {
|
||||
const roomData = useRoomData()
|
||||
const { data: config } = useConfig()
|
||||
|
||||
return (
|
||||
config?.recording?.authenticated_participants_enabled === true &&
|
||||
!!roomData?.id &&
|
||||
!!roomData?.access_level &&
|
||||
OPEN_ACCESS_LEVELS.includes(roomData.access_level)
|
||||
)
|
||||
}
|
||||
|
||||
export const useCanRecord = () => {
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const { isLoggedIn } = useUser()
|
||||
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
|
||||
|
||||
return (
|
||||
isAdminOrOwner || (isLoggedIn === true && isRecordingOpenToParticipants)
|
||||
)
|
||||
}
|
||||
+4
-4
@@ -2,21 +2,21 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { RecordingMode } from '../types'
|
||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||
import { useCanRecord } from './useCanRecord'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import type { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasFeatureWithoutRecordingRights = (
|
||||
export const useHasFeatureWithoutAdminRights = (
|
||||
mode: RecordingMode,
|
||||
featureFlag: FeatureFlags
|
||||
) => {
|
||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||
const canRecord = useCanRecord()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isRecordingModeEnabled &&
|
||||
!canRecord
|
||||
!isAdminOrOwner
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { RecordingMode } from '../types'
|
||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||
import { useCanRecord } from './useCanRecord'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import type { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasRecordingAccess = (
|
||||
@@ -12,11 +12,11 @@ export const useHasRecordingAccess = (
|
||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||
const canRecord = useCanRecord()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
canRecord &&
|
||||
isAdminOrOwner &&
|
||||
isRecordingModeEnabled
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// hooks
|
||||
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
||||
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
||||
export { useHasFeatureWithoutRecordingRights } from './hooks/useHasFeatureWithoutRecordingRights'
|
||||
export {
|
||||
useCanRecord,
|
||||
useIsRecordingOpenToParticipants,
|
||||
} from './hooks/useCanRecord'
|
||||
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
|
||||
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
|
||||
|
||||
// api
|
||||
|
||||
@@ -45,6 +45,7 @@ import { userStore } from '@/stores/user'
|
||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||
import { MeetDevtools } from '@/features/devtools'
|
||||
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
||||
import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioOutputDeviceId'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -120,7 +121,7 @@ export const Conference = ({
|
||||
...VOICE_AUDIO_CONSTRAINTS,
|
||||
},
|
||||
audioOutput: {
|
||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||
deviceId: resolveAudioOutputDeviceId(userConfig.audioOutputDeviceId),
|
||||
},
|
||||
}
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
|
||||
+8
-5
@@ -4,6 +4,7 @@ import { RiVolumeUpLine } from '@remixicon/react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||
import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioOutputDeviceId'
|
||||
|
||||
// Speaker test in the audiooutput menu footer (Meet-style UX). Outputs have
|
||||
// no track: the test plays a bundled file through the selected sink, and
|
||||
@@ -66,6 +67,8 @@ export const OutputSoundTester = ({
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
const outputId = resolveAudioOutputDeviceId(sinkId)
|
||||
|
||||
const latestSinkIdRef = useRef(sinkId)
|
||||
latestSinkIdRef.current = sinkId
|
||||
|
||||
@@ -79,15 +82,15 @@ export const OutputSoundTester = ({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sinkId || !canTestAudioOutput()) return
|
||||
audioRef.current?.setSinkId(sinkId).catch(() => {
|
||||
if (!outputId || !canTestAudioOutput()) return
|
||||
audioRef.current?.setSinkId(outputId).catch(() => {
|
||||
// Re-routing failed (stale or unplugged device): stop the test rather
|
||||
// than keep playing through the previous sink.
|
||||
if (latestSinkIdRef.current === sinkId) {
|
||||
stopPlayback()
|
||||
}
|
||||
})
|
||||
}, [sinkId, stopPlayback])
|
||||
}, [outputId, sinkId, stopPlayback])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
@@ -108,8 +111,8 @@ export const OutputSoundTester = ({
|
||||
// Confirm routing before starting: a no-op when already routed,
|
||||
// but rejects on a stale device id, so the test never plays
|
||||
// through the wrong sink.
|
||||
if (sinkId && canTestAudioOutput()) {
|
||||
await audio.setSinkId(sinkId)
|
||||
if (outputId && canTestAudioOutput()) {
|
||||
await audio.setSinkId(outputId)
|
||||
}
|
||||
await audio.play()
|
||||
setIsPlaying(true)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* `'default'` means "use the system output". Firefox has no device with that
|
||||
* id, so setSinkId('default') rejects. Omit it and the browser keeps the OS
|
||||
* default, which is the same result.
|
||||
*/
|
||||
export const resolveAudioOutputDeviceId = (
|
||||
deviceId: string | undefined
|
||||
): string | undefined => {
|
||||
if (!deviceId || deviceId === 'default') return undefined
|
||||
return deviceId
|
||||
}
|
||||
@@ -507,22 +507,6 @@
|
||||
"buttonLabel": "Anfragen"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Zum Transkribieren anmelden",
|
||||
"body": "In diesem Meeting kann jede angemeldete Person eine Transkription (Beta) starten.",
|
||||
"linkMore": "Dokumentation öffnen",
|
||||
"linkAriaLabel": "Dokumentation zum Transkriptionszugang öffnen – öffnet in neuem Tab",
|
||||
"dividerLabel": "ODER",
|
||||
"login": {
|
||||
"heading": "Anmeldung erforderlich",
|
||||
"body": "Melden Sie sich an und kehren Sie dann zu diesem Bereich zurück, um die Transkription zu starten."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Transkription anfragen",
|
||||
"body": "Die Moderation wird benachrichtigt und kann die Transkription starten.",
|
||||
"buttonLabel": "Anfragen"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Advanced-Funktion",
|
||||
"body": "Diese Funktion ist für Sie nicht verfügbar. Bitte wenden Sie sich an den Support, um weitere Informationen zu erhalten.",
|
||||
@@ -576,22 +560,6 @@
|
||||
"buttonLabel": "Anfrage senden"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Zum Aufnehmen anmelden",
|
||||
"body": "In diesem Meeting kann jede angemeldete Person eine Videoaufnahme (Beta) starten.",
|
||||
"linkMore": "Dokumentation öffnen",
|
||||
"linkAriaLabel": "Dokumentation zum Aufzeichnungszugang öffnen – öffnet in neuem Tab",
|
||||
"dividerLabel": "ODER",
|
||||
"login": {
|
||||
"heading": "Anmeldung erforderlich",
|
||||
"body": "Melden Sie sich an und kehren Sie dann zu diesem Bereich zurück, um die Aufnahme zu starten."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Aufnahme anfragen",
|
||||
"body": "Die Moderation wird benachrichtigt und kann die Aufnahme starten.",
|
||||
"buttonLabel": "Anfrage senden"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Advanced-Funktion",
|
||||
"body": "Diese Funktion ist für Sie nicht verfügbar. Bitte wenden Sie sich an den Support, um weitere Informationen zu erhalten.",
|
||||
|
||||
@@ -507,22 +507,6 @@
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Log in to transcribe",
|
||||
"body": "In this meeting, any logged-in participant can start a transcription (beta).",
|
||||
"linkMore": "Open documentation",
|
||||
"linkAriaLabel": "Open documentation about transcription access - opens in new window",
|
||||
"dividerLabel": "OR",
|
||||
"login": {
|
||||
"heading": "Login Required",
|
||||
"body": "Log in, then come back to this panel to start the transcription."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request Transcription",
|
||||
"body": "The host will be notified and can enable transcription for you.",
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Advanced feature",
|
||||
"body": "This feature is not available to you. Please contact support for more information.",
|
||||
@@ -576,22 +560,6 @@
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Log in to record",
|
||||
"body": "In this meeting, any logged-in participant can start a recording (beta).",
|
||||
"linkMore": "Open documentation",
|
||||
"linkAriaLabel": "Open documentation about recording access - opens in new window",
|
||||
"dividerLabel": "OR",
|
||||
"login": {
|
||||
"heading": "Login Required",
|
||||
"body": "Log in, then come back to this panel to start the recording."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request Recording",
|
||||
"body": "The host will be notified and can enable recording for you.",
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Advanced feature",
|
||||
"body": "This feature is not available to you. Please contact support for more information.",
|
||||
|
||||
@@ -506,22 +506,6 @@
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Inicia sesión para transcribir",
|
||||
"body": "En esta reunión, cualquier participante con sesión iniciada puede iniciar una transcripción (beta).",
|
||||
"linkMore": "Abrir la documentación",
|
||||
"linkAriaLabel": "Abrir la documentación sobre el acceso a la transcripción - se abre en una nueva ventana",
|
||||
"dividerLabel": "O",
|
||||
"login": {
|
||||
"heading": "Inicio de sesión necesario",
|
||||
"body": "Inicia sesión y vuelve a este panel para iniciar la transcripción."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Pedírselo al organizador",
|
||||
"body": "El anfitrión recibirá una notificación y podrá iniciar la transcripción por ti.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Función avanzada",
|
||||
"body": "Esta función no está disponible para ti. Ponte en contacto con el soporte para obtener más información.",
|
||||
@@ -575,22 +559,6 @@
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Inicia sesión para grabar",
|
||||
"body": "En esta reunión, cualquier participante con sesión iniciada puede iniciar una grabación (beta).",
|
||||
"linkMore": "Abrir la documentación",
|
||||
"linkAriaLabel": "Abrir la documentación sobre el acceso a la grabación - se abre en una nueva ventana",
|
||||
"dividerLabel": "O",
|
||||
"login": {
|
||||
"heading": "Inicio de sesión necesario",
|
||||
"body": "Inicia sesión y vuelve a este panel para iniciar la grabación."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Pedírselo al organizador",
|
||||
"body": "El anfitrión recibirá una notificación y podrá iniciar la grabación por ti.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Función avanzada",
|
||||
"body": "Esta función no está disponible para ti. Ponte en contacto con el soporte para obtener más información.",
|
||||
|
||||
@@ -507,22 +507,6 @@
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Connectez-vous pour transcrire",
|
||||
"body": "Dans cette réunion, tout participant connecté peut lancer une transcription (beta).",
|
||||
"linkMore": "Ouvrir la documentation",
|
||||
"linkAriaLabel": "Ouvrir la documentation sur l'accès à la transcription - ouvre dans une nouvelle fenêtre",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Connexion requise",
|
||||
"body": "Connectez-vous, puis revenez sur ce panneau pour lancer la transcription."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Demander à l'organisateur",
|
||||
"body": "L'hôte recevra une notification et pourra démarrer la transcription pour vous.",
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Fonctionnalité avancée",
|
||||
"body": "Cette fonctionnalité ne vous est pas ouverte. Contactez le support pour obtenir plus d'informations.",
|
||||
@@ -576,22 +560,6 @@
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Connectez-vous pour enregistrer",
|
||||
"body": "Dans cette réunion, tout participant connecté peut lancer un enregistrement (beta).",
|
||||
"linkMore": "Ouvrir la documentation",
|
||||
"linkAriaLabel": "Ouvrir la documentation sur l'accès à l'enregistrement - ouvre dans une nouvelle fenêtre",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Connexion requise",
|
||||
"body": "Connectez-vous, puis revenez sur ce panneau pour lancer l'enregistrement."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Demander à l'organisateur",
|
||||
"body": "L'hôte recevra une notification et pourra démarrer l'enregistrement pour vous.",
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Fonctionnalité avancée",
|
||||
"body": "Cette fonctionnalité ne vous est pas ouverte. Contactez le support pour obtenir plus d'informations.",
|
||||
|
||||
@@ -507,22 +507,6 @@
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Log in om te transcriberen",
|
||||
"body": "In deze vergadering kan elke ingelogde deelnemer een transcriptie starten (beta).",
|
||||
"linkMore": "Documentatie openen",
|
||||
"linkAriaLabel": "Documentatie over transcriptietoegang openen - opent in nieuw venster",
|
||||
"dividerLabel": "OF",
|
||||
"login": {
|
||||
"heading": "Inloggen vereist",
|
||||
"body": "Log in en kom daarna terug naar dit paneel om de transcriptie te starten."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Transcriptie aanvragen",
|
||||
"body": "De host wordt op de hoogte gebracht en kan de transcriptie voor u inschakelen.",
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Geavanceerde functie",
|
||||
"body": "Deze functie is niet voor u beschikbaar. Neem contact op met de ondersteuning voor meer informatie.",
|
||||
@@ -576,22 +560,6 @@
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Log in om op te nemen",
|
||||
"body": "In deze vergadering kan elke ingelogde deelnemer een opname starten (beta).",
|
||||
"linkMore": "Documentatie openen",
|
||||
"linkAriaLabel": "Documentatie over opnametoegang openen - opent in nieuw venster",
|
||||
"dividerLabel": "OF",
|
||||
"login": {
|
||||
"heading": "Inloggen vereist",
|
||||
"body": "Log in en kom daarna terug naar dit paneel om de opname te starten."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Opname aanvragen",
|
||||
"body": "De host wordt op de hoogte gebracht en kan de opname voor u inschakelen.",
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Geavanceerde functie",
|
||||
"body": "Deze functie is niet voor u beschikbaar. Neem contact op met de ondersteuning voor meer informatie.",
|
||||
|
||||
@@ -25,12 +25,15 @@ export const menuRecipe = sva({
|
||||
'&[data-focused]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
outline: 'none!',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
outline: 'none!',
|
||||
},
|
||||
'&[data-focus-visible]': {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'focusRing',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -49,6 +52,10 @@ export const menuRecipe = sva({
|
||||
dark: {
|
||||
item: {
|
||||
color: 'white',
|
||||
'&[data-focus-visible]': {
|
||||
outlineColor: 'white!',
|
||||
outlineOffset: '2px!',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user