Compare commits

...

17 Commits

Author SHA1 Message Date
lebaudantoine 89f8480e0b 🚸(frontend) show two initials in the Avatar when possible
Display two initials in the Avatar whenever the participant's name
allows it, instead of a single letter.

A single initial makes it too hard to distinguish participants when
their cameras are off, especially in larger
2026-07-29 00:00:35 +02:00
lebaudantoine d9bf6efa2a 💄(frontend) improve participant name rendering in the list
Rework how the participant name is displayed in the participant
list to show as much of the name as possible before truncating.

When the name has to be truncated, add a tooltip so users can hover
to see the full name.

Requested by users.
2026-07-29 00:00:35 +02:00
lebaudantoine 4cb7412194 (frontend) introduce an "unauthenticated" participant badge
Add a visual badge on participants who are not authenticated, so it
is immediately clear who could be an anonymous participant. This is
a small but explicit security signal in the participant list.

Beyond that, the badge also plays a functional role: since only
authenticated participants can be promoted or demoted, the badge
helps users see at a glance who is eligible for a role change.
2026-07-29 00:00:35 +02:00
lebaudantoine e8df597055 (frontend) notify user when their meeting role changes
Show a notification to the user whenever their role in the meeting
changes, so they immediately see when they have been promoted or
demoted.
2026-07-29 00:00:35 +02:00
lebaudantoine 05dfcd11ca 🐛(frontend) fall back to user.full_name on request-entry
Since the username refactoring, the username in the store could be
undefined when the join input was pre-filled from user.full_name,
because no keystroke was needed to populate the store.

This led to a 400 error on the request-entry endpoint whenever the
user joined without editing the pre-filled name.

Fall back to user.full_name when the store username is missing, so
the endpoint always receives a value.

Acknowledged as a somewhat wobbly fix, but ships as-is until the
underlying flow is reworked.
2026-07-29 00:00:35 +02:00
lebaudantoine b018832fcf (frontend) close admin side panel when the user is demoted
Listen to role changes in the admin panel and close the side panel
if the current user is demoted while it is open. Without this,
unprivileged users could still see the admin side panel until they
closed it manually.

I checked the other features that could be affected by hot role
changes; this was the only one still exposing admin-only UI after a
demotion. Everything else already handles live permission updates
correctly.
2026-07-29 00:00:35 +02:00
lebaudantoine a269160f6f (frontend) allow promoting authenticated participants
Introduce a new feature that lets a user promote one of the
authenticated participants of the meeting to a role with additional
privileges.

Known limitations:

* Only authenticated participants can be promoted, but there is no
  visual indicator yet distinguishing authenticated from anonymous
  participants. This will be added in a follow-up commit.
* The resource_access data fetched in the initial API call becomes
  stale after a promotion. It is not currently used in the product,
  so this is not visible, but it should either be refreshed later
  or removed from the initial fetch.
* Demoting a promoted user turns them into a member, which is still
  a privileged role. This is a deliberate choice until we introduce
  finer-grained tuning of participant roles.
2026-07-29 00:00:35 +02:00
lebaudantoine c3afa84d9b ♻️(frontend) extract closeSidePanel action at the store level
Extract the logic that closes the side panel into a utility function
declared at the store module level, as recommended by Valtio.

This avoids re-creating the function on every render and prevents
extra re-renders in components that use it.
2026-07-29 00:00:35 +02:00
lebaudantoine 8c6752a29f 🐛(backend) allow any string as sub in the API serializer
The API serializer was too restrictive on the `sub` field, expecting
a UUID. This worked in our development and production setups because
our Keycloak is configured to emit UUID subs, but it broke for other
providers.

Per the OIDC spec and the DB model, `sub` can be any string. Align
the serializer with this and accept arbitrary string values.

Fixes #1525.
2026-07-29 00:00:35 +02:00
lebaudantoine 4c57432a03 💄(frontend) render Avatar initials in uppercase
Uppercase the initials rendered in the Avatar so their vertical
centering stays consistent.

With lowercase letters, the initials were slightly shifted toward
the bottom of the Avatar, which broke the alignment.
2026-07-29 00:00:35 +02:00
lebaudantoine 3b7bfd999c 🔥(frontend) remove leftover console.count call
Drop a stray console.count call that was accidentally committed in a
previous PR and had been left in the codebase.
2026-07-29 00:00:35 +02:00
lebaudantoine 7f386b2e2f ♻️(frontend) derive is_administrable from participant metadata
The is_administrable flag was previously read from the room API
response through the room serializer, giving the frontend static
information about the user's rights.

Refactor the frontend so it derives this flag from the participant
role carried in the participant metadata instead.

Two benefits:

* The flag now updates live along with the participant
  attributes/metadata, so role changes are reflected immediately.
* It removes the duplication between the API response and the
  metadata, which both used to determine the user's capabilities.
2026-07-29 00:00:35 +02:00
lebaudantoine c55d8235fd (frontend) add client for the update participant role endpoint
Add the frontend client that calls the update participant role
endpoint. Straightforward API call, no special handling.
2026-07-29 00:00:35 +02:00
lebaudantoine c7b23abd68 (backend) expose is_authenticated in the LiveKit token
Include the is_authenticated flag on the user in the LiveKit token
and participant metadata.

The frontend needs this information (used in the next commit) to
know whether it can offer to promote a user with access to the room
admin.
2026-07-29 00:00:35 +02:00
lebaudantoine 5a641a4366 ♻️(backend) pass the participant role in the LiveKit token
The backend previously passed an abstract is_admin_or_owner boolean
flag in the LiveKit token. That kept the frontend minimalistic and
saved it from having to handle role comparisons.

As we introduce more features that need to distinguish between the
room owner and admins, refactor the token to carry the role
directly. The frontend can then derive the relevant flags from a
richer piece of information.
2026-07-29 00:00:35 +02:00
lebaudantoine f043ad6f98 (backend) add endpoint to update a participant role during a meeting
Add an endpoint that allows updating a user's role while in a
meeting. The goal is to let users promote other connected
participants to admin or moderator, so the burden of administrating
a meeting can be shared.
2026-07-29 00:00:35 +02:00
lebaudantoine 1141c1cecd ️(backend) add permission class checking the user is in the call
Introduce a new permission class that verifies the caller making a
request is both authenticated and actually present in the call.

It will be used to gate actions that require the user to be live in
the room, for example:

* allowing someone in from the waiting room
* promoting another participant to a different role

More generally, this covers every action where, for security
reasons, we need to make sure the user is truly present in the call
and that someone is not reusing their cookie as an API key.
2026-07-29 00:00:35 +02:00
48 changed files with 1251 additions and 248 deletions
+7
View File
@@ -12,6 +12,8 @@ and this project adheres to
- ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item
- ✨(frontend) allow promoting authenticated participants
- ✨(frontend) introduce an "unauthenticated" participant badge
### Changed
@@ -22,6 +24,8 @@ and this project adheres to
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
- ⚡️(frontend) limit unnecessary re-renders #1510
- 📝(legal) update terms of service
- 💄(frontend) render Avatar initials in uppercase
- 💄(frontend) improve participant name rendering in the list
## Fixed
@@ -29,6 +33,9 @@ and this project adheres to
- 🐛(summary) extend tasks auto retry logic
- 🐛(summary) properly detect when failure webhook should be sent
- 🐛(backend) preserve recording metadata when updating room access
- 🐛(backend) allow any string as sub in the API serializer
- 🐛(frontend) fall back to user.full_name on request-entry
- 🚸(frontend) show two initials in the Avatar when possible
## [1.24.0] - 2026-07-21
+32
View File
@@ -6,6 +6,11 @@ from django.http import Http404
from rest_framework import permissions
from ..models import RoleChoices
from ..services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
# LiveKit token scoped to this room
return request.auth.video.room == str(obj.id)
class IsPresentInMeeting(permissions.BasePermission):
"""Check that the requesting user is currently connected to the meeting.
The requester must be session-authenticated (their DB identity is needed
to check privileges); presence is verified against LiveKit using their
`sub` as participant identity. Fails closed on LiveKit errors.
"""
message = "You must be connected to the meeting to perform this action."
def has_object_permission(self, request, view, obj):
"""Verify the requester's identity is a participant of the room."""
user = request.user
if not user or not user.is_authenticated:
return False
try:
return ParticipantsManagement().check_if_in_meeting(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
+12 -5
View File
@@ -183,13 +183,11 @@ class RoomSerializer(serializers.ModelSerializer):
user=request.user,
username=username,
configuration=output["configuration"],
is_admin_or_owner=is_admin_or_owner,
role=role,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
return output
@@ -299,8 +297,8 @@ class RoomInviteSerializer(serializers.Serializer):
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
"""Base serializer for participant management operations."""
participant_identity = serializers.UUIDField(
help_text="LiveKit participant identity (UUID format)"
participant_identity = serializers.CharField(
help_text="LiveKit participant identity (matching the user's sub format)"
)
@@ -312,6 +310,15 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
class ParticipantRoleSerializer(BaseParticipantsManagementSerializer):
"""Validate an in-meeting role change (promotion/demotion) request."""
role = serializers.ChoiceField(
choices=[models.RoleChoices.MEMBER, models.RoleChoices.ADMIN],
help_text="Target role. Ownership cannot be granted this way.",
)
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
+51
View File
@@ -88,6 +88,10 @@ from core.services.room_management import (
RoomManagementException,
RoomNotFoundException,
)
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion
@@ -639,6 +643,53 @@ class RoomViewSet(
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="update-participant-role",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsPresentInMeeting,
],
)
def update_participant_role(self, request, pk=None): # pylint: disable=unused-argument
"""Promote or demote a participant currently connected to the meeting.
Requires the requester to be session-authenticated, have privileges
(admin/owner) on the room, and be connected to the meeting.
If the target participant has a user account, the role is persisted
(`ResourceAccess`) then mirrored to their LiveKit attributes.
If the participant is anonymous, the promotion will fail.
"""
room = self.get_object()
serializer = serializers.ParticipantRoleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
participant_identity = serializer.validated_data["participant_identity"]
role = serializer.validated_data["role"]
if str(request.user.sub) == str(participant_identity):
return drf_response.Response(
{"error": "You cannot change your own role."},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
result = RoomRoleService().set_participant_role(
room=room,
participant_identity=participant_identity,
role=role,
actor=request.user,
)
except RoomRoleError as e:
return drf_response.Response({"error": str(e)}, status=e.status_code)
return drf_response.Response(result, status=drf_status.HTTP_200_OK)
@decorators.action(
detail=True,
methods=["post"],
+17 -7
View File
@@ -104,21 +104,30 @@ class LobbyService:
)
@staticmethod
def can_bypass_lobby(room, user) -> bool:
def can_bypass_lobby(room, user, role) -> bool:
"""Determines if a user can bypass the waiting lobby and join a room directly.
A user can bypass the lobby if:
1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated
2. The room has RESTRICTED access level and the user has any role
Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting.
"""
return room.is_public or (
room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated
return (
room.is_public
or (
room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated
)
or (
room.access_level == models.RoomAccessLevel.RESTRICTED
and user.is_authenticated
and role is not None
)
)
def request_entry(
@@ -144,8 +153,9 @@ class LobbyService:
participant = self._get_participant(room.id, participant_id)
room_id = str(room.id)
user_role = room.get_role(request.user)
if self.can_bypass_lobby(room=room, user=request.user):
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
if participant is None:
participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
@@ -162,8 +172,8 @@ class LobbyService:
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
role=user_role,
)
return participant, livekit_config
@@ -183,8 +193,8 @@ class LobbyService:
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
role=user_role,
)
return participant, livekit_config
+178
View File
@@ -0,0 +1,178 @@
"""Room role management service.
Single entry point for changing a user's role on a room, used by:
- the in-meeting endpoint (promote/demote a connected participant)
- (more to come soon)
`ResourceAccess` is the source of truth. The LiveKit `room_role`
participant attribute is only a projection of it, synced best-effort.
"""
from logging import getLogger
from uuid import UUID
from core import models
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
logger = getLogger(__name__)
class RoomRoleError(Exception):
"""Base exception for room role management errors."""
status_code = 400
class SelfActionError(RoomRoleError):
"""Raised when a user tries to change their own role."""
status_code = 403
class OwnerRoleError(RoomRoleError):
"""Raised when trying to demote an owner or grant ownership."""
status_code = 403
class ParticipantNotInMeetingError(RoomRoleError):
"""Raised when the target participant is not connected to the meeting."""
status_code = 404
class UserNotFoundError(RoomRoleError):
"""Raised when the target participant has no user account in database."""
status_code = 404
ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN)
class RoomRoleService:
"""Manage promotion and demotion of room co-hosts."""
def set_role(
self, room: models.Room, user: models.User, role: str, actor: models.User
):
"""Persist `role` for `user` on `room`, idempotently and atomically.
Returns the up-to-date `ResourceAccess`. Never grants or removes
ownership: granting OWNER is refused, and an existing OWNER access
is never modified.
"""
if role not in ASSIGNABLE_ROLES:
raise OwnerRoleError("Ownership cannot be granted through this action.")
if actor is not None and user == actor:
raise SelfActionError("You cannot change your own role.")
access, created = models.ResourceAccess.objects.get_or_create(
resource=room,
user=user,
defaults={"role": role},
)
if created:
return access
if access.role == models.RoleChoices.OWNER:
raise OwnerRoleError("Room owners cannot be demoted.")
if access.role != role:
access.role = role
access.save(update_fields=["role", "updated_at"])
return access
def set_participant_role(
self,
room: models.Room,
participant_identity: UUID,
role: str,
actor: models.User,
):
"""Change the role of a participant currently connected to the meeting.
- The participant must be connected (checked against LiveKit).
- The participant must map to a user account.
- The role is persisted in DB then mirrored to LiveKit.
Returns a dict: {"role", "livekit_synced"}.
"""
room_name = str(room.pk)
participants_management = ParticipantsManagement()
try:
is_in_meeting = participants_management.check_if_in_meeting(
room_name=room_name, identity=str(participant_identity)
)
except ParticipantNotFoundException as e:
raise ParticipantNotInMeetingError(
"Participant is not connected to this meeting."
) from e
if not is_in_meeting:
raise ParticipantNotInMeetingError(
"Participant is not connected to this meeting."
)
user = models.User.objects.filter(sub=participant_identity).first()
if user is None:
raise UserNotFoundError(
"This participant has no user account and cannot be assigned a role."
)
# Source of truth first: even if the LiveKit sync below fails,
# the role is real and any fresh token will carry it.
self.set_role(room=room, user=user, role=role, actor=actor)
livekit_synced = self._sync_livekit_role(
room_name=room_name,
participant_identity=str(participant_identity),
role=str(role),
)
return {
"role": role,
"livekit_synced": livekit_synced,
}
@staticmethod
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
"""Mirror the role to the participant's LiveKit attributes.
Best-effort: returns False on failure instead of raising, so callers
can report a partial success. Re-running the action re-syncs.
"""
try:
ParticipantsManagement().update(
room_name=room_name,
identity=participant_identity,
attributes={"room_role": role},
)
except ParticipantNotFoundException:
# The participant left between the presence check and the update:
# harmless, the DB state (if any) remains authoritative.
logger.info(
"Participant %s left room %s before role sync",
participant_identity,
room_name,
)
return False
except ParticipantsManagementException:
logger.exception(
"Could not sync role to LiveKit for participant %s in room %s",
participant_identity,
room_name,
)
return False
return True
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
other_room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(other_room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
@@ -146,7 +146,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
room = RoomFactory(configuration={"everyone_can_mute": False})
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -293,7 +293,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
)
# Token identity matches the admin user so LiveKitTokenAuthentication
# resolves request.user back to the admin.
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -323,7 +323,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
# Token is scoped to a DIFFERENT room, and admin status must only be
# honored when established via session, never via a LiveKit
# token, which can be replayed off-host.
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
token = utils.generate_token(str(other_room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
role=random.choice(["administrator", "owner"]),
)
# The token is the only credential.
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
token = utils.generate_token(str(room.id), admin_user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
token = utils.generate_token(str(room.id), user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
payload = {"participant_identity": ["test"]}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Must be a valid UUID." in str(response.data)
assert "Not a valid string." in str(response.data)
def test_update_participant_no_update_fields():
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
payload = {"participant_identity": ["invalid-uuid"]}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
@@ -12,7 +12,7 @@ import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
from ...models import RoomAccessLevel
from ...models import RoleChoices, RoomAccessLevel
pytestmark = pytest.mark.django_db
@@ -31,7 +31,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -51,7 +50,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"configuration": {},
"access_level": "trusted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -70,7 +68,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -87,7 +84,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -104,7 +100,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -214,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"configuration": {},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -261,7 +255,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -278,7 +271,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
username=None,
color=None,
sources=["camera"],
is_admin_or_owner=False,
role=None,
participant_id=None,
)
@@ -313,7 +306,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"configuration": {},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -330,7 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
username=None,
color=None,
sources=None,
is_admin_or_owner=False,
role=None,
participant_id=None,
)
@@ -355,7 +347,6 @@ def test_api_rooms_retrieve_authenticated():
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"slug": room.slug,
}
@@ -401,7 +392,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": expected_name,
@@ -418,7 +408,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
username=None,
color=None,
sources=["camera"],
is_admin_or_owner=False,
role=str(RoleChoices.MEMBER),
participant_id=None,
)
@@ -493,7 +483,6 @@ def test_api_rooms_retrieve_administrators(
assert content_dict == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": True,
"configuration": {},
"livekit": {
"url": "test_url_value",
@@ -511,6 +500,6 @@ def test_api_rooms_retrieve_administrators(
username=None,
color=None,
sources=None,
is_admin_or_owner=True,
role=str(user_access.role),
participant_id=None,
)
@@ -0,0 +1,319 @@
"""
Test rooms API endpoints in the Meet core app: update-participant-role.
"""
# pylint: disable=redefined-outer-name,unused-argument
import uuid
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import ResourceAccess, RoleChoices
from ...services.participants_management import ParticipantNotFoundException
pytestmark = pytest.mark.django_db
def test_update_participant_role_anonymous():
"""Anonymous requesters are rejected."""
client = APIClient()
room = RoomFactory()
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 401
def test_update_participant_role_requires_privileges():
"""A simple member cannot promote other participants."""
client = APIClient()
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 403
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_requester_not_in_meeting(mock_perm_pm):
"""An admin who is not connected to the meeting is rejected."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = False
client = APIClient()
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "administrator"},
format="json",
)
assert response.status_code == 403
mock_perm_pm.return_value.check_if_in_meeting.assert_called_once_with(
room_name=str(room.pk), identity=str(user.sub)
)
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_cannot_target_self(mock_perm_pm):
"""Requesters cannot change their own role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
client = APIClient()
user = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": user.sub, "role": "member"},
format="json",
)
assert response.status_code == 403
assert response.json() == {"error": "You cannot change your own role."}
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_promotes_authenticated_target(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting a connected, authenticated participant persists the role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
assert response.json() == {
"role": "administrator",
"livekit_synced": True,
}
access = ResourceAccess.objects.get(resource=room, user=target)
assert access.role == RoleChoices.ADMIN
mock_sync.assert_called_once_with(
room_name=str(room.pk),
participant_identity=str(target.sub),
role="administrator",
)
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_demotes_authenticated_target(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Demoting a connected admin back to member updates the access row."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "member"},
format="json",
)
assert response.status_code == 200
access = ResourceAccess.objects.get(resource=room, user=target)
assert access.role == RoleChoices.MEMBER
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_cannot_demote_owner(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Room owners can never be demoted."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
client = APIClient()
admin = UserFactory()
owner = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.ADMIN), (owner, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(owner.sub), "role": "member"},
format="json",
)
assert response.status_code == 403
assert response.json() == {"error": "Room owners cannot be demoted."}
assert (
ResourceAccess.objects.get(resource=room, user=owner).role == RoleChoices.OWNER
)
mock_sync.assert_not_called()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_anonymous_target_is_ephemeral(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting an anonymous participant should not be possible."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
anonymous_identity = uuid.uuid4()
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": anonymous_identity, "role": "administrator"},
format="json",
)
assert response.status_code == 404
assert response.json() == {
"error": "This participant has no user account and cannot be assigned a role."
}
assert not ResourceAccess.objects.filter(resource=room).exclude(user=admin).exists()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_target_not_in_meeting(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Only connected participants can be promoted or demoted."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.side_effect = (
ParticipantNotFoundException("Participant does not exist")
)
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 404
assert not ResourceAccess.objects.filter(resource=room, user=target).exists()
mock_sync.assert_not_called()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_is_idempotent_and_resyncs(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""Promoting an existing admin succeeds and still re-syncs LiveKit."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = True
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
mock_sync.assert_called_once()
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
@mock.patch("core.services.room_roles.ParticipantsManagement")
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_livekit_failure_reports_partial_success(
mock_perm_pm, mock_svc_pm, mock_sync
):
"""A LiveKit sync failure does not lose the persisted role."""
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
mock_sync.return_value = False
client = APIClient()
admin = UserFactory()
target = UserFactory(sub=uuid.uuid4())
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
client.force_login(admin)
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": str(target.sub), "role": "administrator"},
format="json",
)
assert response.status_code == 200
assert response.json() == {
"role": "administrator",
"livekit_synced": False,
}
assert (
ResourceAccess.objects.get(resource=room, user=target).role == RoleChoices.ADMIN
)
@mock.patch("core.api.permissions.ParticipantsManagement")
def test_update_participant_role_rejects_owner_role(mock_perm_pm):
"""The owner role can never be granted through this endpoint."""
client = APIClient()
admin = UserFactory()
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
client.force_login(admin)
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
response = client.post(
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
{"participant_identity": "some-identity", "role": "owner"},
format="json",
)
assert response.status_code == 400
+72 -17
View File
@@ -9,13 +9,14 @@ import uuid
from unittest import mock
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
from django.http import HttpResponse
import pytest
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.models import RoleChoices, RoomAccessLevel
from core.services.lobby import (
LobbyParticipant,
LobbyParticipantNotFound,
@@ -188,17 +189,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
def test_can_bypass_lobby_public_room(lobby_service):
"""Should return True for public rooms regardless of user auth."""
"""Should return True for public rooms regardless of user auth and role."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is True
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
# Authenticated user
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
@@ -208,7 +209,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
# Authenticated user
user = mock.Mock()
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
@@ -218,21 +219,34 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
def test_can_bypass_lobby_private_room(lobby_service):
"""Should return False for private rooms regardless of user auth."""
"""Should return False for private rooms regardless of user auth if role is not."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
# Authenticated user
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is False
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
@pytest.mark.parametrize(
"role",
[RoleChoices.MEMBER, RoleChoices.ADMIN, RoleChoices.OWNER],
)
def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
"""Should return True for private rooms if the user is authenticated and has any role."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = mock.Mock()
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user, role=role) is True
@mock.patch("core.utils.generate_livekit_config")
@@ -241,7 +255,7 @@ def test_request_entry_public_room(
):
"""Test requesting entry to a public room."""
request = mock.Mock()
request.user = mock.Mock()
request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -266,8 +280,8 @@ def test_request_entry_public_room(
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -279,8 +293,7 @@ def test_request_entry_trusted_room(
):
"""Test requesting entry to a trusted room when the user is authenticated."""
request = mock.Mock()
request.user = mock.Mock()
request.user.is_authenticated = True
request.user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
@@ -305,8 +318,8 @@ def test_request_entry_trusted_room(
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -319,6 +332,7 @@ def test_request_entry_new_participant(
"""Test requesting entry for a new participant."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -348,6 +362,7 @@ def test_request_entry_waiting_participant(
"""Test requesting entry for a waiting participant."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
request.user = AnonymousUser()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -374,7 +389,7 @@ def test_request_entry_accepted_participant(
):
"""Test requesting entry for an accepted participant."""
request = mock.Mock()
request.user = mock.Mock()
request.user = AnonymousUser()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -400,8 +415,48 @@ def test_request_entry_accepted_participant(
username=username,
color="#123456",
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
role=None,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_participant_with_role(
mock_generate_config, lobby_service, participant_id, username
):
"""Test requesting entry for a participant with a role on the room."""
request = mock.Mock()
request.user = UserFactory()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
username=username,
color="#123456",
configuration=room.configuration,
participant_id="test-participant-id",
role="administrator",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
+11 -6
View File
@@ -65,7 +65,7 @@ def generate_token(
username: Optional[str] = None,
color: Optional[str] = None,
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
role: Optional[str] = None,
participant_id: Optional[str] = None,
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -79,7 +79,7 @@ def generate_token(
If none, a value will be generated
sources: (Optional[List[str]]): List of media sources the user can publish
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
is_admin_or_owner (bool): Whether user has admin privileges
role (Optional[str]): Room's access role if any
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
@@ -87,6 +87,7 @@ def generate_token(
str: The LiveKit JWT access token.
"""
is_admin_or_owner = role in ("owner", "administrator")
if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES
@@ -127,7 +128,11 @@ def generate_token(
.with_identity(identity)
.with_name(display_name)
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
{
"color": color,
"room_role": role,
"is_authenticated": "true" if user.is_authenticated else "false",
}
)
)
@@ -138,7 +143,7 @@ def generate_livekit_config(
room_id: str,
user,
username: str,
is_admin_or_owner: bool,
role: Optional[str] = None,
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
@@ -149,7 +154,7 @@ def generate_livekit_config(
room_id: Room identifier
user: User instance requesting access
username: Display name in room
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
role (str): Room's access role if any
color (Optional[str]): Optional color to associate with the participant.
configuration (Optional[dict]): Room configuration dict that can override default settings.
participant_id (Optional[str]): Stable identifier for anonymous users;
@@ -172,7 +177,7 @@ def generate_livekit_config(
username=username,
color=color,
sources=sources,
is_admin_or_owner=is_admin_or_owner,
role=role,
participant_id=participant_id,
),
}
+12 -3
View File
@@ -28,6 +28,15 @@ const avatar = cva({
},
})
const getInitials = (name?: string): string => {
if (!name) return ''
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ''
const first = words[0].charAt(0)
const second = words.length > 1 ? words[1].charAt(0) : ''
return (first + second).toUpperCase()
}
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
name?: string
bgColor?: string
@@ -35,7 +44,7 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
export const Avatar = React.memo(
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
const initial = name?.trim()?.charAt(0) ?? ''
const initials = getInitials(name)
return (
<div
style={{ backgroundColor: bgColor, ...style }}
@@ -52,11 +61,11 @@ export const Avatar = React.memo(
y="50"
textAnchor="middle"
dominantBaseline="central"
fontSize="52"
fontSize={initials.length > 1 ? 48 : 52}
fontWeight="500"
fill="currentColor"
>
{initial}
{initials.toUpperCase()}
</text>
</svg>
</div>
@@ -168,6 +168,31 @@ export const MainNotificationToast = () => {
}
}, [room, triggerNotificationSoundIfRoomIsSmall])
useEffect(() => {
const handleAttributeChanged = (
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,
}
)
}
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
return () => {
room.off(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
}
}, [room])
useEffect(() => {
const removeParticipantNotifications = (participant: Participant) => {
toastQueue.visibleToasts.forEach((toast) => {
@@ -14,4 +14,5 @@ export const NotificationDuration = {
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
REACTION_RECEIVED: ToastDuration.SHORT,
RECORDING_REQUESTED: ToastDuration.LONG,
ROLE_CHANGED: ToastDuration.LONG,
} as const
@@ -17,4 +17,5 @@ export enum NotificationType {
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged',
}
@@ -14,6 +14,7 @@ import { ToastRecordingSaving } from './ToastRecordingSaving'
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest'
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -70,6 +71,9 @@ const renderToast = (
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
)
case NotificationType.RoleChanged:
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
default:
return <Toast key={toast.key} toast={toast} state={state} />
}
@@ -0,0 +1,28 @@
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 ToastRoleChanged({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', { keyPrefix: 'roleChanged' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const newRole = t(`roles.${props.toast.content.newRole}`)
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t('body', { role: newRole })}
</HStack>
</StyledToastContainer>
)
}
@@ -0,0 +1,31 @@
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
export const useParticipantRole = () => {
const data = useRoomData()
const updateParticipantRole = async (
identity: string,
role: AssignableParticipantRole
) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
try {
return fetchApi(`rooms/${data.id}/update-participant-role/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: identity,
role: role,
}),
})
} catch (error) {
console.error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
return { updateParticipantRole }
}
@@ -0,0 +1,68 @@
import React, { useLayoutEffect, useRef, useState } from 'react'
import { Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
export const ParticipantName = React.memo(
({ displayedName, isLocal }: { displayedName: string; isLocal: boolean }) => {
const { t } = useTranslation('rooms')
const nameRef = useRef<HTMLParagraphElement>(null)
const [isTruncated, setIsTruncated] = useState(false)
useLayoutEffect(() => {
const el = nameRef.current
if (!el) return
const truncated = el.scrollWidth > el.clientWidth
setIsTruncated((prev) => (prev === truncated ? prev : truncated))
}, [displayedName])
return (
<Text
as="div"
variant="sm"
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
minWidth: 0,
width: 'full',
maxWidth: 'full',
})}
>
<VisualOnlyTooltip
tooltip={displayedName}
disabled={!isTruncated}
className={css({ display: 'flex', minWidth: 0, flex: 1 })}
>
<p
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
minWidth: 0,
})}
ref={nameRef}
>
{displayedName}
</p>
</VisualOnlyTooltip>
{isLocal && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
flexShrink: 0,
})}
>
({t('participants.you')})
</span>
)}
</Text>
)
}
)
ParticipantName.displayName = 'ParticipantName'
@@ -5,7 +5,11 @@ import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin'
import {
getParticipantIsRoomAdmin,
getParticipantIsRoomOwner,
getParticipantIsRoomMember,
} from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import { type LocalParticipant, type Participant, Track } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import {
@@ -20,7 +24,9 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = {
participant: Participant
@@ -106,52 +112,26 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
width: 'full',
})}
>
<HStack>
<div
className={css({
position: 'relative',
})}
>
<HStack flex="1" minW="0">
<div className={css({ position: 'relative', flexShrink: 0 })}>
<Avatar name={name} bgColor={getParticipantColor(participant)} />
<PinBadge participant={participant} />
<UnauthenticatedBadge participant={participant} />
</div>
<VStack gap={0} alignItems="start">
<Text
variant="sm"
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
<VStack gap={0} alignItems="start" minW="0" flex="1">
<ParticipantName
displayedName={name}
isLocal={isLocal(participant)}
/>
<Text variant="xsNote">
{getParticipantIsRoomOwner(participant) && t('participants.host')}
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
{getParticipantIsRoomMember(participant) &&
t('participants.member')}
</Text>
{getParticipantIsRoomAdmin(participant) && (
<Text variant="xsNote">{t('participants.host')}</Text>
)}
</VStack>
</HStack>
<HStack>
<HStack flexShrink={0}>
<MicIndicator participant={participant} />
<ParticipantMenuButton participant={participant} />
</HStack>
@@ -1,7 +1,6 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar'
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
@@ -11,6 +10,7 @@ import { isLocal } from '@/utils/livekit'
import { RiHand } from '@remixicon/react'
import { Button } from '@/primitives'
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
import { ParticipantName } from './ParticipantName'
const ActionButton = ({
participant,
@@ -42,9 +42,7 @@ type HandRaisedListItemProps = {
}
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
return (
<HStack
role="listitem"
@@ -56,42 +54,15 @@ export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
width: 'full',
})}
>
<HStack>
<HStack flex="1" minW="0" overflow="hidden">
<Avatar name={name} bgColor={getParticipantColor(participant)} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '120px',
display: 'block',
})}
>
{name}
</span>
{isLocal(participant) && (
<span
className={css({
marginLeft: '.25rem',
whiteSpace: 'nowrap',
})}
>
({t('participants.you')})
</span>
)}
</Text>
<ParticipantName displayedName={name} isLocal={isLocal(participant)} />
</HStack>
<HStack flexShrink={0}>
<AdminOrOwnerOnly>
<ActionButton participant={participant} name={name} />
</AdminOrOwnerOnly>
</HStack>
<AdminOrOwnerOnly>
<ActionButton participant={participant} name={name} />
</AdminOrOwnerOnly>
</HStack>
)
}
@@ -0,0 +1,43 @@
import { Participant } from 'livekit-client'
import { css } from '@/styled-system/css'
import { useParticipantAttribute } from '@livekit/components-react'
import { RiErrorWarningFill } from '@remixicon/react'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useTranslation } from 'react-i18next'
export const UnauthenticatedBadge = ({
participant,
}: {
participant: Participant
}) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'participants.unauthenticated',
})
const is_authenticated = useParticipantAttribute('is_authenticated', {
participant,
})
if (is_authenticated == 'true') return
return (
<div
className={css({
height: '18px',
width: '18px',
borderRadius: '100%',
background: 'orange.200',
color: 'orange.800',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
bottom: '-2px',
right: '-4px',
})}
>
<VisualOnlyTooltip tooltip={t('badge')}>
<RiErrorWarningFill size={14} aria-hidden />
</VisualOnlyTooltip>
</div>
)
}
@@ -1,10 +1,11 @@
import { Button, Text } from '@/primitives'
import { Button } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '../api/listWaitingParticipants'
import { RiCloseLine } from '@remixicon/react'
import { ParticipantName } from './ParticipantName'
export const WaitingParticipantRow = ({
participant,
@@ -26,42 +27,11 @@ export const WaitingParticipantRow = ({
width: 'full',
})}
>
<HStack
className={css({
flex: '1',
minWidth: '0',
})}
>
<HStack flex="1" minW="0">
<Avatar name={participant.username} bgColor={participant.color} />
<Text
variant={'sm'}
className={css({
userSelect: 'none',
cursor: 'default',
display: 'flex',
flex: '1',
minWidth: '0',
})}
>
<span
className={css({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
width: '100%',
display: 'block',
})}
>
{participant.username}
</span>
</Text>
<ParticipantName displayedName={participant.username} isLocal={false} />
</HStack>
<HStack
gap="0.25rem"
className={css({
flexShrink: '0',
})}
>
<HStack gap="0.25rem" flexShrink={0}>
<Button
size="sm"
variant="tertiary"
@@ -1,24 +1,46 @@
import { Menu as RACMenu } from 'react-aria-components'
import type { Participant } from 'livekit-client'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { PinMenuItem } from './items/PinMenuItem'
import { RemoveMenuItem } from './items/RemoveMenuItem'
import { PromoteMenuItem } from './items/PromoteMenuItem'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useParticipantAttributes } from '@livekit/components-react'
export const ParticipantMenu = ({
participant,
}: {
participant: Participant
}) => {
const isAdminOrOwner = useIsAdminOrOwner()
const canModerateParticipant = !participant.isLocal && isAdminOrOwner
const isLocalUserAdminOrOwner = useIsAdminOrOwner()
const { attributes } = useParticipantAttributes({ participant })
const isAdmin = attributes?.room_role === 'administrator'
const isOwner = attributes?.room_role === 'owner'
const isAuthenticated = attributes?.is_authenticated == 'true'
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
return (
<RACMenu
style={{
minWidth: '75px',
minWidth: '100px',
}}
>
<PinMenuItem participant={participant} />
{canModerateParticipant && <RemoveMenuItem participant={participant} />}
{canManage && (
<RemoveMenuItem
identity={participant.identity}
displayedName={participant.name}
/>
)}
{canManage && isAuthenticated && (
<PromoteMenuItem
identity={participant.identity}
isAdmin={isAdmin}
displayedName={participant.name}
/>
)}
</RACMenu>
)
}
@@ -1,7 +1,6 @@
import { Button, Menu } from '@/primitives'
import { RiMore2Fill } from '@remixicon/react'
import { ParticipantMenu } from './ParticipantMenu'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import type { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
@@ -11,8 +10,6 @@ export const ParticipantMenuButton = ({
participant: Participant
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
const isAdminOrOwner = useIsAdminOrOwner()
if (!isAdminOrOwner) return null
return (
<Menu>
<Button
@@ -0,0 +1,47 @@
import React, { useCallback } from 'react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { RiAdminLine, RiUserMinusLine } from '@remixicon/react'
import { useParticipantRole } from '@/features/participants/api/updateParticipantRole'
import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx'
type PromoteMenuItemProps = {
identity: string
displayedName?: string
isAdmin: boolean
}
export const PromoteMenuItem = React.memo(
({ identity, displayedName, isAdmin }: PromoteMenuItemProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
const { updateParticipantRole } = useParticipantRole()
const label = isAdmin ? 'demote' : 'promote'
const Icon = isAdmin ? RiUserMinusLine : RiAdminLine
const toggleRole = useCallback(
() =>
updateParticipantRole(identity, isAdmin ? 'member' : 'administrator'),
[isAdmin, updateParticipantRole, identity]
)
return (
<MenuItem
aria-label={t(`${label}.ariaLabel`, {
name: displayedName || identity,
})}
className={menuRecipe({ icon: true }).item}
onAction={toggleRole}
>
<HStack gap={0.25} minWidth={280}>
<Icon size={20} aria-hidden />
{t(`${label}.label`)}
</HStack>
</MenuItem>
)
}
)
PromoteMenuItem.displayName = 'PromoteMenuItem'
@@ -1,4 +1,4 @@
import type { Participant } from 'livekit-client'
import React from 'react'
import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx'
import { RiCloseLine } from '@remixicon/react'
@@ -6,23 +6,30 @@ import { MenuItem } from 'react-aria-components'
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
import { useTranslation } from 'react-i18next'
export const RemoveMenuItem = ({
participant,
}: {
participant: Participant
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu.remove' })
const { removeParticipant } = useRemoveParticipant()
return (
<MenuItem
aria-label={t('ariaLabel', { name: participant.name })}
className={menuRecipe({ icon: true }).item}
onAction={() => removeParticipant(participant)}
>
<HStack gap={0.25}>
<RiCloseLine size={20} aria-hidden />
{t('label')}
</HStack>
</MenuItem>
)
type RemoveMenuItemProps = {
identity: string
displayedName?: string
}
export const RemoveMenuItem = React.memo(
({ identity, displayedName }: RemoveMenuItemProps) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'participantMenu.remove',
})
const { removeParticipant } = useRemoveParticipant()
return (
<MenuItem
aria-label={t('ariaLabel', { name: displayedName || identity })}
className={menuRecipe({ icon: true }).item}
onAction={() => removeParticipant(identity)}
>
<HStack gap={0.25}>
<RiCloseLine size={20} aria-hidden />
{t('label')}
</HStack>
</MenuItem>
)
}
)
RemoveMenuItem.displayName = 'RemoveMenuItem'
@@ -28,3 +28,6 @@ export type ApiRoom = {
livekit?: ApiLiveKit
configuration?: RoomConfiguration
}
export type ParticipantRole = 'member' | 'administrator' | 'owner'
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
@@ -1,11 +1,10 @@
import type { Participant } from 'livekit-client'
import { useRoomData } from '../livekit/hooks/useRoomData'
import { fetchApi } from '@/api/fetchApi'
export const useRemoveParticipant = () => {
const data = useRoomData()
const removeParticipant = async (participant: Participant) => {
const removeParticipant = async (identity: string) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
@@ -13,7 +12,7 @@ export const useRemoveParticipant = () => {
return fetchApi(`rooms/${data.id}/remove-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
participant_identity: identity,
}),
})
}
@@ -316,7 +316,7 @@ export const Join = ({
refetch: refetchRoom,
} = useQuery({
queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId, username }),
queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
retry: false,
enabled: false,
@@ -339,7 +339,7 @@ export const Join = ({
const { status, startWaiting } = useLobby({
roomId,
username,
username: username || user?.full_name || 'anonymous',
onAccepted: handleAccepted,
})
@@ -11,6 +11,9 @@ import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
import { usePermissionsManager } from '../hooks/usePermissionsManager'
import { useEffect } from 'react'
import { closeSidePanel } from '@/stores/layout'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -23,6 +26,14 @@ export const Admin = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
const isAdminOrOwner = useIsAdminOrOwner()
useEffect(() => {
if (!isAdminOrOwner) {
closeSidePanel()
}
}, [isAdminOrOwner])
const { data: readOnlyData } = useQuery({
queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId }),
@@ -1,4 +1,4 @@
import { layoutStore } from '@/stores/layout'
import { closeSidePanel, layoutStore } from '@/stores/layout'
import { css } from '@/styled-system/css'
import { Heading } from 'react-aria-components'
import { text } from '@/primitives/Text'
@@ -199,10 +199,7 @@ export const SidePanel = () => {
ref={asideRef}
title={title}
ariaLabel={t('ariaLabel', { title })}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}}
onClose={closeSidePanel}
closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`),
})}
@@ -1,6 +1,17 @@
import { useRoomData } from './useRoomData'
import {
useParticipantAttribute,
useRoomContext,
} from '@livekit/components-react'
import { ParticipantRole } from '@/features/rooms/api/ApiRoom'
export const useIsAdminOrOwner = () => {
const apiRoomData = useRoomData()
return apiRoomData?.is_administrable
const room = useRoomContext()
const localParticipant = room.localParticipant
const role = useParticipantAttribute('room_role', {
participant: localParticipant,
})
return (
role !== undefined &&
['administrator', 'owner'].includes(role as ParticipantRole)
)
}
@@ -8,7 +8,7 @@ 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 { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin'
import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import Source = Track.Source
import {
NotificationType,
@@ -48,7 +48,7 @@ export const usePublishSourcesManager = () => {
})
const unprivilegedRemoteParticipants = remoteParticipants.filter(
(participant) => !getParticipantIsRoomAdmin(participant)
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
)
const currentSources = useMemo(() => {
@@ -0,0 +1,8 @@
import type { Participant } from 'livekit-client'
export const getParticipantIsAuthenticated = (
participant: Participant
): boolean => {
const isAuthenticated = participant.attributes?.is_authenticated
return isAuthenticated == 'true'
}
@@ -1,12 +0,0 @@
import type { Participant } from 'livekit-client'
export const getParticipantIsRoomAdmin = (
participant: Participant
): boolean => {
const attributes = participant.attributes
if (!attributes) {
return false
}
return attributes?.room_admin === 'true'
}
@@ -0,0 +1,23 @@
import type { Participant } from 'livekit-client'
import { ParticipantRole } from '@/features/rooms/api/ApiRoom'
const participantHasRoomRole = (
participant: Participant,
roles: ParticipantRole[]
): boolean => {
const role = participant.attributes?.room_role
return role !== undefined && roles.includes(role as ParticipantRole)
}
export const getParticipantIsRoomAdmin = (participant: Participant): boolean =>
participantHasRoomRole(participant, ['administrator'])
export const getParticipantIsRoomOwner = (participant: Participant): boolean =>
participantHasRoomRole(participant, ['owner'])
export const getParticipantIsRoomMember = (participant: Participant): boolean =>
participantHasRoomRole(participant, ['member'])
export const getParticipantIsRoomAdminOrOwner = (
participant: Participant
): boolean => participantHasRoomRole(participant, ['administrator', 'owner'])
@@ -54,5 +54,13 @@
"default": "Deine Aufzeichnung wird gespeichert! Du erhältst eine E-Mail, sobald sie bereit ist."
}
},
"roleChanged": {
"body": "Deine Rolle wurde geändert. Du bist jetzt {{role}}.",
"roles": {
"owner": "Host",
"administrator": "Co-host",
"member": "Mitglied"
}
},
"openMenu": "Menü öffnen"
}
+13
View File
@@ -583,11 +583,16 @@
"you": "Du",
"unknown": "Unbekannte Person",
"host": "Host",
"cohost": "Co-host",
"member": "Mitglied",
"contributors": "Teilnehmende",
"collapsable": {
"open": "{{name}}-Liste öffnen",
"close": "{{name}}-Liste schließen"
},
"unauthenticated": {
"badge": "Nicht authentifizierter Benutzer"
},
"muteYourself": "Mikrofon ausschalten",
"muteParticipant": "{{name}}s Mikrofon ausschalten",
"muteParticipantAlert": {
@@ -627,6 +632,14 @@
"pin": {
"label": "Anheften",
"ariaLabel": "Hefte {{name}} an"
},
"promote": {
"label": "Zum Co-Host machen",
"ariaLabel": "{{name}} zum Co-Host machen"
},
"demote": {
"label": "Co-Host-Rolle entfernen",
"ariaLabel": "Die Co-Host-Rolle von {{name}} entfernen"
}
},
"pinAnnouncements": {
@@ -54,5 +54,13 @@
"default": "Your recording is being saved! Well send a notification to your email as soon as its ready."
}
},
"roleChanged": {
"body": "Your role has changed. You are now {{role}}.",
"roles": {
"owner": "Host",
"administrator": "Co-host",
"member": "Member"
}
},
"openMenu": "Open menu"
}
+13
View File
@@ -582,11 +582,16 @@
"you": "You",
"unknown": "Unknown participant",
"host": "Host",
"cohost": "Co-host",
"member": "Member",
"contributors": "Contributors",
"collapsable": {
"open": "Open {{name}} list",
"close": "Close {{name}} list"
},
"unauthenticated": {
"badge": "User is not authenticated"
},
"muteYourself": "Close your mic",
"muteParticipant": "Close the mic of {{name}}",
"muteParticipantAlert": {
@@ -626,6 +631,14 @@
"pin": {
"label": "Pin",
"ariaLabel": "Pin {{name}}"
},
"promote": {
"label": "Make co-host",
"ariaLabel": "Make {{name}} a co-host"
},
"demote": {
"label": "Remove co-host role",
"ariaLabel": "Remove {{name}}'s co-host role"
}
},
"pinAnnouncements": {
@@ -54,5 +54,13 @@
"default": "Nous finalisons votre enregistrement ! Vous recevrez un e-mail dès quil sera prêt."
}
},
"roleChanged": {
"body": "Votre rôle a changé, vous êtes maitenant {{role}}.",
"roles": {
"owner": "Organisateur",
"administrator": "Co-organisateur",
"member": "Membre"
}
},
"openMenu": "Ouvrir le menu"
}
+13
View File
@@ -583,10 +583,15 @@
"unknown": "Participant inconnu",
"contributors": "Contributeurs",
"host": "Organisateur de la réunion",
"cohost": "Co-organisateur",
"member": "Membre",
"collapsable": {
"open": "Ouvrir la liste {{name}}",
"close": "Fermer la liste {{name}}"
},
"unauthenticated": {
"badge": "Utilisateur non authentifié"
},
"muteYourself": "Couper votre micro",
"muteParticipant": "Couper le micro de {{name}}",
"muteParticipantAlert": {
@@ -626,6 +631,14 @@
"pin": {
"label": "Épingler",
"ariaLabel": "Épingler {{name}}"
},
"promote": {
"label": "Nommer co-organisateur",
"ariaLabel": "Nommer {{name}} co-organisateur"
},
"demote": {
"label": "Retirer le rôle de co-organisateur",
"ariaLabel": "Retirer le rôle de co-organisateur à {{name}}"
}
},
"pinAnnouncements": {
@@ -54,5 +54,13 @@
"default": "We zijn uw opname aan het voltooien! U ontvangt een e-mail zodra deze klaar is."
}
},
"roleChanged": {
"body": "Je rol is gewijzigd. Je bent nu {{role}}.",
"roles": {
"owner": "Host",
"administrator": "Co-host",
"member": "Lid"
}
},
"openMenu": "Menu openen"
}
+13
View File
@@ -582,11 +582,16 @@
"you": "U",
"unknown": "Onbekende deelnemer",
"host": "Host",
"cohost": "Co-host",
"member": "Lid",
"contributors": "Deelnemers",
"collapsable": {
"open": "Open {{name}} lijst",
"close": "Sluit {{name}} lijst"
},
"unauthenticated": {
"badge": "Niet authentifizierter Benutzer"
},
"muteYourself": "Uw microfoon dempen",
"muteParticipant": "Demp de microfoon van {{name}}",
"muteParticipantAlert": {
@@ -626,6 +631,14 @@
"pin": {
"label": "Pinnen",
"ariaLabel": "Maak {{name}} vast"
},
"promote": {
"label": "Co-host maken",
"ariaLabel": "{{name}} co-host maken"
},
"demote": {
"label": "Co-hostrol verwijderen",
"ariaLabel": "De co-hostrol van {{name}} verwijderen"
}
},
"pinAnnouncements": {
@@ -16,24 +16,19 @@ export type VisualOnlyTooltipProps = {
tooltip: string
ariaLabel?: string
tooltipPosition?: 'top' | 'bottom'
disabled?: boolean
className?: string
}
/**
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
*
* This is necessary because TooltipTrigger from react-aria-components automatically adds
* aria-describedby on the button, which links the tooltip for accessibility.
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication.
* This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication.
*
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
*/
export const VisualOnlyTooltip = ({
type VisualOnlyTooltipInnerProps = Omit<VisualOnlyTooltipProps, 'disabled'>
const VisualOnlyTooltipInner = ({
children,
tooltip,
ariaLabel,
className,
tooltipPosition = 'top',
}: VisualOnlyTooltipProps) => {
}: VisualOnlyTooltipInnerProps) => {
const [isVisible, setIsVisible] = useState(false)
const { getContainer } = useUNSAFE_PortalContext()
const wrapperRef = useRef<HTMLDivElement>(null)
@@ -135,6 +130,7 @@ export const VisualOnlyTooltip = ({
onMouseLeave={hideTooltip}
onFocus={showTooltip}
onBlur={hideTooltip}
className={className}
>
{wrappedChild}
</div>
@@ -183,3 +179,25 @@ export const VisualOnlyTooltip = ({
</>
)
}
/**
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
*
* This is necessary because TooltipTrigger from react-aria-components automatically adds
* aria-describedby on the button, which links the tooltip for accessibility.
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication.
* This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication.
*
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
*/
export const VisualOnlyTooltip = ({
children,
disabled,
...props
}: VisualOnlyTooltipProps) => {
if (disabled) {
return children
}
return <VisualOnlyTooltipInner {...props}>{children}</VisualOnlyTooltipInner>
}
-1
View File
@@ -64,7 +64,6 @@ export const clearTextAreaValue = () => {
}
export function resetChatStore() {
console.count('resetChatStore')
Object.assign(chatStore, {
...initialState,
rows: [],
+5
View File
@@ -34,3 +34,8 @@ export const setPinnedTrack = (trackRef: TrackReferenceOrPlaceholder): void => {
export const clearPinnedTrack = (): void => {
layoutStore.pinnedTrackRef = undefined
}
export const closeSidePanel = (): void => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
}