Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine e7f9522943 ✨(frontend) allow hosts to make a participant speaker
When a host locks microphone, camera or screen sharing for the
room, the only way to let one person speak was to promote them to
co-host, which also grants moderation powers, or to unlock the room
for everyone. Promotion is not even possible for anonymous
participants, who are often the ones invited to ask a question.

A speaker is exempt from the room locks without any other
privilege. Speakers get the same publish sources as co-hosts, so a
deployment that restricts its default sources stays consistent.
Room-wide toggles leave speakers untouched; once nothing is locked
anymore the role has no purpose and is cleared.

The role lives in LiveKit only, through the existing
update-participant endpoint, so it is lost on reconnection. The
host can grant it again; persisting it would require a backend
change that is not worth it yet.

In an upcoming commit, when a participant who is not a speaker
raises their hand in a locked room, the notification will let the
host make them speaker directly.
2026-10-11 01:03:37 +02:00
40 changed files with 393 additions and 804 deletions
-1
View File
@@ -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
+1 -2
View File
@@ -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. |
-1
View File
@@ -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 | |
-3
View File
@@ -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,
+18 -37
View File
@@ -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"
+7 -3
View File
@@ -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")
-23
View File
@@ -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()
-8
View File
@@ -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
)
-1
View File
@@ -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
@@ -177,18 +177,31 @@ export const MainNotificationToast = () => {
changedAttributes: Record<string, string>,
participant: Participant
) => {
if (!participant.isLocal || !('room_role' in changedAttributes)) return
const newRole = changedAttributes['room_role']
toastQueue.add(
{
participant,
type: NotificationType.RoleChanged,
newRole: newRole,
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
if (!participant.isLocal) return
if ('room_role' in changedAttributes) {
toastQueue.add(
{
participant,
type: NotificationType.RoleChanged,
newRole: changedAttributes['room_role'],
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
}
if ('is_speaker' in changedAttributes) {
toastQueue.add(
{
participant,
type: NotificationType.SpeakerChanged,
isSpeaker: changedAttributes['is_speaker'] === 'true',
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
}
}
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
@@ -22,4 +22,5 @@ export enum NotificationType {
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged',
SpeakerChanged = 'speakerChanged',
}
@@ -15,6 +15,7 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest'
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
import { ToastSpeakerChanged } from './ToastSpeakerChanged'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -78,6 +79,9 @@ const renderToast = (
case NotificationType.RoleChanged:
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
case NotificationType.SpeakerChanged:
return <ToastSpeakerChanged key={toast.key} toast={toast} state={state} />
default:
return <Toast key={toast.key} toast={toast} state={state} />
}
@@ -0,0 +1,27 @@
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer'
export function ToastSpeakerChanged({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', { keyPrefix: 'speakerChanged' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(props.toast.content.isSpeaker ? 'granted' : 'removed')}
</HStack>
</StyledToastContainer>
)
}
@@ -0,0 +1,62 @@
import type { Participant, Track } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useDefaultPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
import { reportError } from '@/features/analytics/telemetry'
export const SPEAKER_ATTRIBUTE = 'is_speaker'
export const getParticipantIsSpeaker = (participant: Participant): boolean =>
participant.attributes?.[SPEAKER_ATTRIBUTE] === 'true'
export const useParticipantSpeaker = () => {
const data = useRoomData()
const defaultSources = useDefaultPublishSources()
const updateSpeaker = async (
participant: Participant,
isSpeaker: boolean,
sources: Track.Source[]
) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const attributes: Record<string, string> = {
[SPEAKER_ATTRIBUTE]: isSpeaker ? 'true' : '',
}
try {
return await fetchApi(`rooms/${data.id}/update-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
attributes,
permission: {
can_subscribe: participant.permissions?.canSubscribe,
can_publish_data: participant.permissions?.canPublishData,
can_update_metadata: participant.permissions?.canUpdateMetadata,
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
can_publish: sources.length > 0,
can_publish_sources: sources,
},
}),
})
} catch (error) {
reportError('permissions_api_failure', error, {
context: `Failed to update speaker status of ${participant.identity}`,
})
throw error
}
}
const makeSpeaker = (participant: Participant) =>
updateSpeaker(participant, true, defaultSources)
const removeSpeaker = (
participant: Participant,
roomSources: Track.Source[]
) => updateSpeaker(participant, false, roomSources)
return { makeSpeaker, removeSpeaker }
}
@@ -14,6 +14,7 @@ import { type LocalParticipant, type Participant, Track } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import {
useIsSpeaking,
useParticipantAttribute,
useTrackMutedIndicator,
} from '@livekit/components-react'
import Source = Track.Source
@@ -86,6 +87,16 @@ type ParticipantListItemProps = {
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
const isSpeaker =
useParticipantAttribute('is_speaker', { participant }) === 'true'
const roleLabels = [
getParticipantIsRoomOwner(participant) && t('participants.host'),
getParticipantIsRoomAdmin(participant) && t('participants.cohost'),
getParticipantIsRoomMember(participant) && t('participants.member'),
isSpeaker && t('participants.speaker'),
].filter(Boolean)
return (
<HStack
role="listitem"
@@ -107,12 +118,7 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
displayedName={name}
isLocal={isLocal(participant)}
/>
<Text variant="xsNote">
{getParticipantIsRoomOwner(participant) && t('participants.host')}
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
{getParticipantIsRoomMember(participant) &&
t('participants.member')}
</Text>
<Text variant="xsNote">{roleLabels.join(' · ')}</Text>
</VStack>
</HStack>
<HStack flexShrink={0}>
@@ -3,8 +3,10 @@ import type { Participant } from 'livekit-client'
import { PinMenuItem } from './items/PinMenuItem'
import { RemoveMenuItem } from './items/RemoveMenuItem'
import { PromoteMenuItem } from './items/PromoteMenuItem'
import { SpeakerMenuItem } from './items/SpeakerMenuItem'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useParticipantAttributes } from '@livekit/components-react'
import { useHasLockedSource } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
export const ParticipantMenu = ({
participant,
@@ -18,8 +20,13 @@ export const ParticipantMenu = ({
const isAdmin = attributes?.room_role === 'administrator'
const isOwner = attributes?.room_role === 'owner'
const isAuthenticated = attributes?.is_authenticated == 'true'
const isSpeaker = attributes?.is_speaker === 'true'
const hasLockedSource = useHasLockedSource()
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
const canToggleSpeaker =
canManage && !isAdmin && (isSpeaker || hasLockedSource)
return (
<RACMenu
@@ -41,6 +48,9 @@ export const ParticipantMenu = ({
displayedName={participant.name}
/>
)}
{canToggleSpeaker && (
<SpeakerMenuItem participant={participant} isSpeaker={isSpeaker} />
)}
</RACMenu>
)
}
@@ -0,0 +1,50 @@
import React, { useCallback } from 'react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import type { Participant } from 'livekit-client'
import { RiMicOffLine, RiUserVoiceLine } from '@remixicon/react'
import { useParticipantSpeaker } from '@/features/participants/api/updateParticipantSpeaker'
import { useRoomPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx'
type SpeakerMenuItemProps = {
participant: Participant
isSpeaker: boolean
}
export const SpeakerMenuItem = React.memo(
({ participant, isSpeaker }: SpeakerMenuItemProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
const { makeSpeaker, removeSpeaker } = useParticipantSpeaker()
const roomSources = useRoomPublishSources()
const label = isSpeaker ? 'removeSpeaker' : 'makeSpeaker'
const Icon = isSpeaker ? RiMicOffLine : RiUserVoiceLine
const toggleSpeaker = useCallback(
() =>
isSpeaker
? removeSpeaker(participant, roomSources)
: makeSpeaker(participant),
[isSpeaker, participant, roomSources, makeSpeaker, removeSpeaker]
)
return (
<MenuItem
aria-label={t(`${label}.ariaLabel`, {
name: participant.name || participant.identity,
})}
className={menuRecipe({ icon: true }).item}
onAction={toggleSpeaker}
>
<HStack gap={0.25} minWidth={280}>
<Icon size={20} aria-hidden />
{t(`${label}.label`)}
</HStack>
</MenuItem>
)
}
)
SpeakerMenuItem.displayName = 'SpeakerMenuItem'
@@ -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)
)
}
@@ -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 -5
View File
@@ -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
@@ -1,12 +1,19 @@
import { RoomEvent, Track } from 'livekit-client'
import { useCallback, useMemo } from 'react'
import { useConfig } from '@/api/useConfig'
import { useCallback } from 'react'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRemoteParticipants } from '@livekit/components-react'
import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import {
getParticipantIsSpeaker,
useParticipantSpeaker,
} from '@/features/participants/api/updateParticipantSpeaker'
import {
useDefaultPublishSources,
useRoomPublishSources,
} from '@/features/rooms/livekit/hooks/useRoomPublishSources'
import Source = Track.Source
import {
NotificationType,
@@ -33,8 +40,9 @@ export const usePublishSourcesManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
const data = useRoomData()
const { data: configData } = useConfig()
const configuration = data?.configuration
const currentSources = useRoomPublishSources()
const defaultSources = useDefaultPublishSources()
const { notifyParticipants } = useNotifyParticipants()
@@ -42,25 +50,18 @@ export const usePublishSourcesManager = () => {
const roomId = data?.slug
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
const { removeSpeaker } = useParticipantSpeaker()
const remoteParticipants = useRemoteParticipants({
updateOnlyOn: [RoomEvent.ParticipantAttributesChanged],
})
const unprivilegedRemoteParticipants = remoteParticipants.filter(
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
const restrictedRemoteParticipants = remoteParticipants.filter(
(participant) =>
!getParticipantIsRoomAdminOrOwner(participant) &&
!getParticipantIsSpeaker(participant)
)
const currentSources = useMemo(() => {
const defaultSources = configData?.livekit?.default_sources ?? []
if (
configuration?.can_publish_sources == undefined ||
!Array.isArray(configuration?.can_publish_sources)
) {
return defaultSources
}
return configuration.can_publish_sources
}, [configData, configuration?.can_publish_sources])
const speakers = remoteParticipants.filter(getParticipantIsSpeaker)
const updateSource = useCallback(
async (sources: Source[], enabled: boolean) => {
@@ -84,10 +85,16 @@ export const usePublishSourcesManager = () => {
})
await updateParticipantsPermissions(
unprivilegedRemoteParticipants,
restrictedRemoteParticipants,
newSources
)
if (isSubsetOf(defaultSources, newSources)) {
await Promise.all(
speakers.map((speaker) => removeSpeaker(speaker, newSources))
)
}
if (!enabled) {
/*
* We can't rely solely on the ParticipantPermissionsChanged event here,
@@ -96,7 +103,7 @@ export const usePublishSourcesManager = () => {
* */
await notifyParticipants({
type: NotificationType.PermissionsRemoved,
destinationIdentities: unprivilegedRemoteParticipants.map(
destinationIdentities: restrictedRemoteParticipants.map(
(p) => p.identity
),
additionalData: {
@@ -119,9 +126,12 @@ export const usePublishSourcesManager = () => {
notifyParticipants,
configuration,
currentSources,
defaultSources,
roomId,
patchRoom,
unprivilegedRemoteParticipants,
restrictedRemoteParticipants,
speakers,
removeSpeaker,
updateParticipantsPermissions,
]
)
@@ -0,0 +1,30 @@
import { Track } from 'livekit-client'
import { useMemo } from 'react'
import { useConfig } from '@/api/useConfig'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import Source = Track.Source
const EMPTY_SOURCES: Source[] = []
export const useDefaultPublishSources = (): Source[] => {
const { data: configData } = useConfig()
return configData?.livekit?.default_sources ?? EMPTY_SOURCES
}
export const useRoomPublishSources = (): Source[] => {
const data = useRoomData()
const defaultSources = useDefaultPublishSources()
const roomSources = data?.configuration?.can_publish_sources
return useMemo(
() => (Array.isArray(roomSources) ? roomSources : defaultSources),
[defaultSources, roomSources]
)
}
export const useHasLockedSource = (): boolean => {
const defaultSources = useDefaultPublishSources()
const roomSources = useRoomPublishSources()
return !isSubsetOf(defaultSources, roomSources)
}
@@ -68,5 +68,9 @@
"member": "Mitglied"
}
},
"speakerChanged": {
"granted": "Du bist jetzt Sprecher. Du kannst dein Mikrofon, deine Kamera und die Bildschirmfreigabe nutzen.",
"removed": "Du bist nicht mehr Sprecher."
},
"openMenu": "Menü öffnen"
}
+9 -32
View File
@@ -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.",
@@ -691,6 +659,7 @@
"host": "Host",
"cohost": "Co-host",
"member": "Mitglied",
"speaker": "Sprecher",
"contributors": "Teilnehmende",
"collapsable": {
"open": "{{name}}-Liste öffnen",
@@ -746,6 +715,14 @@
"demote": {
"label": "Co-Host-Rolle entfernen",
"ariaLabel": "Die Co-Host-Rolle von {{name}} entfernen"
},
"makeSpeaker": {
"label": "Zum Sprecher machen",
"ariaLabel": "{{name}} zum Sprecher machen"
},
"removeSpeaker": {
"label": "Sprecherrolle entfernen",
"ariaLabel": "Sprecherrolle von {{name}} entfernen"
}
},
"pinAnnouncements": {
@@ -68,5 +68,9 @@
"member": "Member"
}
},
"speakerChanged": {
"granted": "You are now a speaker. You can use your microphone, camera and screen sharing.",
"removed": "You are no longer a speaker."
},
"openMenu": "Open menu"
}
+9 -32
View File
@@ -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.",
@@ -691,6 +659,7 @@
"host": "Host",
"cohost": "Co-host",
"member": "Member",
"speaker": "Speaker",
"contributors": "Contributors",
"collapsable": {
"open": "Open {{name}} list",
@@ -746,6 +715,14 @@
"demote": {
"label": "Remove co-host role",
"ariaLabel": "Remove {{name}}'s co-host role"
},
"makeSpeaker": {
"label": "Make speaker",
"ariaLabel": "Make {{name}} a speaker"
},
"removeSpeaker": {
"label": "Remove speaker",
"ariaLabel": "Remove {{name}} as speaker"
}
},
"pinAnnouncements": {
@@ -68,5 +68,9 @@
"member": "Miembro"
}
},
"speakerChanged": {
"granted": "Ahora eres ponente. Puedes usar tu micrófono, tu cámara y compartir pantalla.",
"removed": "Ya no eres ponente."
},
"openMenu": "Abrir el menú"
}
+9 -32
View File
@@ -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.",
@@ -691,6 +659,7 @@
"host": "Organizador de la reunión",
"cohost": "Coorganizador",
"member": "Miembro",
"speaker": "Ponente",
"collapsable": {
"open": "Abrir la lista {{name}}",
"close": "Cerrar la lista {{name}}"
@@ -745,6 +714,14 @@
"demote": {
"label": "Retirar el rol de coorganizador",
"ariaLabel": "Retirar el rol de coorganizador a {{name}}"
},
"makeSpeaker": {
"label": "Nombrar ponente",
"ariaLabel": "Nombrar ponente a {{name}}"
},
"removeSpeaker": {
"label": "Quitar rol de ponente",
"ariaLabel": "Quitar el rol de ponente a {{name}}"
}
},
"pinAnnouncements": {
@@ -68,5 +68,9 @@
"member": "Membre"
}
},
"speakerChanged": {
"granted": "Vous êtes maintenant intervenant. Vous pouvez utiliser votre micro, votre caméra et partager votre écran.",
"removed": "Vous n'êtes plus intervenant."
},
"openMenu": "Ouvrir le menu"
}
+9 -32
View File
@@ -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.",
@@ -692,6 +660,7 @@
"host": "Organisateur de la réunion",
"cohost": "Co-organisateur",
"member": "Membre",
"speaker": "Intervenant",
"collapsable": {
"open": "Ouvrir la liste {{name}}",
"close": "Fermer la liste {{name}}"
@@ -746,6 +715,14 @@
"demote": {
"label": "Retirer le rôle de co-organisateur",
"ariaLabel": "Retirer le rôle de co-organisateur à {{name}}"
},
"makeSpeaker": {
"label": "Désigner comme intervenant",
"ariaLabel": "Désigner {{name}} comme intervenant"
},
"removeSpeaker": {
"label": "Retirer le rôle d'intervenant",
"ariaLabel": "Retirer le rôle d'intervenant de {{name}}"
}
},
"pinAnnouncements": {
@@ -68,5 +68,9 @@
"member": "Lid"
}
},
"speakerChanged": {
"granted": "Je bent nu spreker. Je kunt je microfoon, camera en schermdeling gebruiken.",
"removed": "Je bent geen spreker meer."
},
"openMenu": "Menu openen"
}
+9 -32
View File
@@ -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.",
@@ -691,6 +659,7 @@
"host": "Host",
"cohost": "Co-host",
"member": "Lid",
"speaker": "Spreker",
"contributors": "Deelnemers",
"collapsable": {
"open": "Open {{name}} lijst",
@@ -746,6 +715,14 @@
"demote": {
"label": "Co-hostrol verwijderen",
"ariaLabel": "De co-hostrol van {{name}} verwijderen"
},
"makeSpeaker": {
"label": "Spreker maken",
"ariaLabel": "{{name}} spreker maken"
},
"removeSpeaker": {
"label": "Sprekersrol verwijderen",
"ariaLabel": "Sprekersrol van {{name}} verwijderen"
}
},
"pinAnnouncements": {