diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index f7db2125..3b84021a 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -526,3 +526,15 @@ class CreateFileSerializer(ListFileSerializer): def update(self, instance, validated_data): raise NotImplementedError("Update method can not be used.") + + +class RaiseHandSerializer(BaseValidationOnlySerializer): + """Serializer for raising or lowering a participant's hand in a room.""" + + raised = serializers.BooleanField() + + +class RenameParticipantSerializer(BaseValidationOnlySerializer): + """Serializer for renaming a participant in a room.""" + + name = serializers.CharField(min_length=1, max_length=255, allow_blank=False) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8375ccd1..e924748c 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -10,6 +10,7 @@ from django.core.files.storage import default_storage from django.db.models import Q from django.http import Http404 from django.shortcuts import get_object_or_404 +from django.utils import timezone from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ @@ -678,6 +679,82 @@ class RoomViewSet( {"status": "success"}, status=drf_status.HTTP_200_OK ) + @decorators.action( + detail=True, + methods=["post"], + url_path="toggle-hand", + url_name="toggle-hand", + permission_classes=[permissions.HasLiveKitRoomAccess], + authentication_classes=[LiveKitTokenAuthentication], + ) + def toggle_hand(self, request, pk=None): # pylint: disable=unused-argument + """Raise or lower the current participant's hand in the room.""" + room = self.get_object() + + serializer = serializers.RaiseHandSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + identity = request.auth.identity + + # LiveKit uses the handRaisedAt participant attribute to signal hand state. + # An empty string means the hand is lowered; a non-empty ISO 8601 timestamp + # means the hand is raised. The timestamp is used by clients to determine + # the order in which participants raised their hands. + hand_raised_at = ( + timezone.now().isoformat() if serializer.validated_data["raised"] else "" + ) + + try: + ParticipantsManagement().update( + room_name=str(room.pk), + identity=identity, + attributes={"handRaisedAt": hand_raised_at}, + ) + except ParticipantsManagementException: + return drf_response.Response( + {"error": "Failed to update participant hand state"}, + status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return drf_response.Response( + {"status": "success"}, + status=drf_status.HTTP_200_OK, + ) + + @decorators.action( + detail=True, + methods=["post"], + url_path="rename", + url_name="rename", + permission_classes=[permissions.HasLiveKitRoomAccess], + authentication_classes=[LiveKitTokenAuthentication], + ) + def rename(self, request, pk=None): # pylint: disable=unused-argument + """Rename the current participant in the room.""" + room = self.get_object() + + serializer = serializers.RenameParticipantSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + identity = request.auth.identity + + try: + ParticipantsManagement().update( + room_name=str(room.pk), + identity=identity, + name=serializer.validated_data["name"], + ) + except ParticipantsManagementException: + return drf_response.Response( + {"error": "Failed to rename participant"}, + status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return drf_response.Response( + {"status": "success"}, + status=drf_status.HTTP_200_OK, + ) + class ResourceAccessViewSet( mixins.CreateModelMixin, diff --git a/src/backend/core/tests/rooms/test_api_rooms_rename_toggle.py b/src/backend/core/tests/rooms/test_api_rooms_rename_toggle.py new file mode 100644 index 00000000..a4f5db2e --- /dev/null +++ b/src/backend/core/tests/rooms/test_api_rooms_rename_toggle.py @@ -0,0 +1,463 @@ +""" +Test rooms API endpoints: toggle hand and rename participant. +""" + +# pylint: disable=redefined-outer-name,unused-argument,protected-access + +from unittest import mock +from uuid import uuid4 + +from django.contrib.auth.models import AnonymousUser +from django.urls import reverse + +import pytest +from freezegun import freeze_time +from livekit.api import TwirpError +from rest_framework import status +from rest_framework.test import APIClient + +from core import utils +from core.factories import RoomFactory, UserFactory + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def mock_livekit_client(): + """Mock LiveKit API client.""" + with mock.patch("core.utils.create_livekit_client") as mock_create: + mock_client = mock.AsyncMock() + mock_create.return_value = mock_client + yield mock_client + + +@pytest.fixture +def room(): + """Create a room.""" + return RoomFactory() + + +@pytest.fixture +def user(): + """Create a user.""" + return UserFactory() + + +@pytest.fixture +def token(room, user): + """Generate a real LiveKit JWT for the user in the room.""" + return utils.generate_token(room=str(room.id), user=user) + + +@pytest.fixture +def anonymous_token(room): + """Generate a real LiveKit JWT for an anonymous user in the room.""" + return utils.generate_token( + room=str(room.id), + user=AnonymousUser(), + participant_id="anon-participant-id", + ) + + +# --- +# toggle-hand +# --- + + +def test_toggle_hand_raise_success(mock_livekit_client, room, token): + """Test successfully raising a participant's hand.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post(url, {"raised": True, "token": token}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + mock_livekit_client.room.update_participant.assert_called_once() + mock_livekit_client.aclose.assert_called_once() + + +def test_toggle_hand_lower_success(mock_livekit_client, room, token): + """Test successfully lowering a participant's hand.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post(url, {"raised": False, "token": token}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + call_kwargs = mock_livekit_client.room.update_participant.call_args + assert call_kwargs[0][0].attributes["handRaisedAt"] == "" + + mock_livekit_client.aclose.assert_called_once() + + +def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token): + """Test that raising a hand sets a non-empty ISO timestamp as the attribute.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post(url, {"raised": True, "token": token}, format="json") + + assert response.status_code == status.HTTP_200_OK + + call_kwargs = mock_livekit_client.room.update_participant.call_args + assert call_kwargs[0][0].attributes["handRaisedAt"] != "" + + +def test_toggle_hand_identity_derived_from_token( + mock_livekit_client, room, token, user +): + """Test that the participant identity is derived from the token, not supplied by the client.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + client.post(url, {"raised": True, "token": token}, format="json") + + call_kwargs = mock_livekit_client.room.update_participant.call_args + assert call_kwargs[0][0].identity == str(user.sub) + + +def test_toggle_hand_missing_raised_field(room, token): + """Test toggle hand with missing raised field returns 400.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post(url, {"token": token}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "raised" in response.data + + +def test_toggle_hand_invalid_raised_field(room, token): + """Test toggle hand with non-boolean raised field returns 400.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post( + url, {"raised": "not-a-boolean", "token": token}, format="json" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_toggle_hand_forbidden_without_token(room): + """Test toggle hand returns 403 when no LiveKit token is provided.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post(url, {"raised": True}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_toggle_hand_forbidden_token_for_wrong_room(user): + """Test toggle hand returns 403 when the token is scoped to a different room.""" + wrong_room = RoomFactory() + target_room = RoomFactory() + wrong_token = utils.generate_token(room=str(wrong_room.id), user=user) + + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id}) + response = client.post(url, {"raised": True, "token": wrong_token}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token): + """Test toggle hand when LiveKit API raises TwirpError.""" + mock_livekit_client.room.update_participant.side_effect = TwirpError( + msg="Internal server error", code="unknown", status=500 + ) + + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post(url, {"raised": True, "token": token}, format="json") + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.data == {"error": "Failed to update participant hand state"} + + mock_livekit_client.aclose.assert_called_once() + + +def test_toggle_hand_raise_success_anonymous( + mock_livekit_client, room, anonymous_token +): + """Test successfully raising hand as an anonymous participant.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post( + url, {"raised": True, "token": anonymous_token}, format="json" + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + mock_livekit_client.room.update_participant.assert_called_once() + mock_livekit_client.aclose.assert_called_once() + + +def test_toggle_hand_lower_success_anonymous( + mock_livekit_client, room, anonymous_token +): + """Test successfully lowering hand as an anonymous participant.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post( + url, {"raised": False, "token": anonymous_token}, format="json" + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + call_kwargs = mock_livekit_client.room.update_participant.call_args + assert call_kwargs[0][0].attributes["handRaisedAt"] == "" + + +def test_toggle_hand_identity_derived_from_token_anonymous( + mock_livekit_client, room, anonymous_token +): + """Test that identity is derived from participant_id for anonymous users.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + client.post(url, {"raised": True, "token": anonymous_token}, format="json") + + call_kwargs = mock_livekit_client.room.update_participant.call_args + assert call_kwargs[0][0].identity == "anon-participant-id" + + +# --- +# rename +# --- + + +def test_rename_participant_success(mock_livekit_client, room, token): + """Test successfully renaming a participant.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post(url, {"name": "John Doe", "token": token}, format="json") + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + mock_livekit_client.room.update_participant.assert_called_once() + mock_livekit_client.aclose.assert_called_once() + + +def test_rename_participant_sets_correct_name(mock_livekit_client, room, token): + """Test that rename passes the correct name to LiveKit.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + client.post(url, {"name": "Jane Doe", "token": token}, format="json") + + call_kwargs = mock_livekit_client.room.update_participant.call_args + assert call_kwargs[0][0].name == "Jane Doe" + + +def test_rename_participant_uses_identity_from_token( + mock_livekit_client, room, token, user +): + """Test that rename derives participant identity from the LiveKit token, not the request.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + client.post(url, {"name": "John Doe", "token": token}, format="json") + + call_kwargs = mock_livekit_client.room.update_participant.call_args + assert call_kwargs[0][0].identity == str(user.sub) + + +def test_rename_participant_empty_name(room, token): + """Test rename with an empty name returns 400.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post(url, {"name": "", "token": token}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "name" in response.data + + +def test_rename_participant_missing_name(room, token): + """Test rename with missing name field returns 400.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post(url, {"token": token}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "name" in response.data + + +def test_rename_participant_name_too_long(room, token): + """Test rename with a name exceeding 255 characters returns 400.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post(url, {"name": "a" * 256, "token": token}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "name" in response.data + + +def test_rename_participant_forbidden_without_token(room): + """Test rename returns 403 when no LiveKit token is provided.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post(url, {"name": "John Doe"}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_rename_participant_forbidden_token_for_wrong_room(user): + """Test rename returns 403 when the token is scoped to a different room.""" + wrong_room = RoomFactory() + target_room = RoomFactory() + wrong_token = utils.generate_token(room=str(wrong_room.id), user=user) + + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": target_room.id}) + response = client.post( + url, {"name": "John Doe", "token": wrong_token}, format="json" + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, token): + """Test rename when LiveKit API raises TwirpError.""" + mock_livekit_client.room.update_participant.side_effect = TwirpError( + msg="Internal server error", code="unknown", status=500 + ) + + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post(url, {"name": "John Doe", "token": token}, format="json") + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.data == {"error": "Failed to rename participant"} + + mock_livekit_client.aclose.assert_called_once() + + +def test_rename_participant_success_anonymous( + mock_livekit_client, room, anonymous_token +): + """Test successfully renaming an anonymous participant.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post( + url, {"name": "Guest User", "token": anonymous_token}, format="json" + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data == {"status": "success"} + + mock_livekit_client.room.update_participant.assert_called_once() + mock_livekit_client.aclose.assert_called_once() + + +def test_rename_participant_uses_identity_from_token_anonymous( + mock_livekit_client, room, anonymous_token +): + """Test that rename derives identity from participant_id for anonymous users.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + client.post(url, {"name": "Guest User", "token": anonymous_token}, format="json") + + call_kwargs = mock_livekit_client.room.update_participant.call_args + assert call_kwargs[0][0].identity == "anon-participant-id" + + +def test_rename_participant_sets_correct_name_anonymous( + mock_livekit_client, room, anonymous_token +): + """Test that rename passes the correct name to LiveKit for anonymous users.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + client.post(url, {"name": "Guest User", "token": anonymous_token}, format="json") + + call_kwargs = mock_livekit_client.room.update_participant.call_args + assert call_kwargs[0][0].name == "Guest User" + + +def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_token): + """Test rename returns 403 when anonymous token is scoped to a different room.""" + target_room = RoomFactory() + + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": target_room.id}) + response = client.post( + url, {"name": "Guest User", "token": anonymous_token}, format="json" + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# --- +# expired / malformed / missing room — shared cases +# --- + + +@pytest.fixture +@freeze_time("2023-01-15 12:00:00") +def expired_token(room, user): + """Generate a LiveKit JWT frozen in the past, guaranteed to be expired.""" + return utils.generate_token(room=str(room.id), user=user) + + +def test_toggle_hand_expired_token(room, expired_token): + """Test toggle hand returns 403 when the LiveKit token is expired.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post(url, {"raised": True, "token": expired_token}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_rename_participant_expired_token(room, expired_token): + """Test rename returns 403 when the LiveKit token is expired.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post( + url, {"name": "John Doe", "token": expired_token}, format="json" + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_toggle_hand_malformed_token(room): + """Test toggle hand returns 403 when the LiveKit token is malformed.""" + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": room.id}) + response = client.post( + url, {"raised": True, "token": "this-is-not-a-valid-jwt"}, format="json" + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_toggle_hand_room_not_found(user): + """Test toggle hand returns 404 when the room does not exist.""" + non_existent_room_id = uuid4() + token = utils.generate_token(room=str(non_existent_room_id), user=user) + + client = APIClient() + url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id}) + response = client.post(url, {"raised": True, "token": token}, format="json") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_rename_participant_malformed_token(room): + """Test rename returns 403 when the LiveKit token is malformed.""" + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": room.id}) + response = client.post( + url, {"name": "John Doe", "token": "this-is-not-a-valid-jwt"}, format="json" + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_rename_participant_room_not_found(user): + """Test rename returns 404 when the room does not exist.""" + non_existent_room_id = uuid4() + token = utils.generate_token(room=str(non_existent_room_id), user=user) + + client = APIClient() + url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id}) + response = client.post(url, {"name": "John Doe", "token": token}, format="json") + + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 3afba6bc..cbacbe1e 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -96,7 +96,7 @@ def generate_token( room=room, room_join=True, room_admin=is_admin_or_owner, - can_update_own_metadata=True, + can_update_own_metadata=False, can_publish=bool(sources), can_publish_sources=sources, can_subscribe=True, diff --git a/src/frontend/src/features/rooms/api/renameParticipant.ts b/src/frontend/src/features/rooms/api/renameParticipant.ts new file mode 100644 index 00000000..dfe35205 --- /dev/null +++ b/src/frontend/src/features/rooms/api/renameParticipant.ts @@ -0,0 +1,28 @@ +import { fetchApi } from '@/api/fetchApi' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' + +export const useRenameParticipant = () => { + const data = useRoomData() + + const renameParticipant = async (name: string) => { + if (!data?.id) { + throw new Error('Room id is not available') + } + + const token = data?.livekit?.token + + if (!token) { + throw new Error('LiveKit token is not available') + } + + return fetchApi(`rooms/${data.id}/rename/`, { + method: 'POST', + body: JSON.stringify({ + name, + token, + }), + }) + } + + return { renameParticipant } +} diff --git a/src/frontend/src/features/rooms/api/updateRaiseHand.ts b/src/frontend/src/features/rooms/api/updateRaiseHand.ts new file mode 100644 index 00000000..12014ef0 --- /dev/null +++ b/src/frontend/src/features/rooms/api/updateRaiseHand.ts @@ -0,0 +1,28 @@ +import { fetchApi } from '@/api/fetchApi' +import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' + +export const useRaiseHand = () => { + const data = useRoomData() + + const raiseHand = async (raised: boolean) => { + if (!data?.id) { + throw new Error('Room id is not available') + } + + const token = data?.livekit?.token + + if (!token) { + throw new Error('LiveKit token is not available') + } + + return fetchApi(`rooms/${data.id}/toggle-hand/`, { + method: 'POST', + body: JSON.stringify({ + raised, + token, + }), + }) + } + + return { raiseHand } +} diff --git a/src/frontend/src/features/rooms/livekit/hooks/useRaisedHand.ts b/src/frontend/src/features/rooms/livekit/hooks/useRaisedHand.ts index 7c401eb1..dac0bf48 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useRaisedHand.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useRaisedHand.ts @@ -1,10 +1,11 @@ -import { LocalParticipant, Participant } from 'livekit-client' +import { Participant } from 'livekit-client' import { useParticipantAttribute, useParticipants, } from '@livekit/components-react' import { isLocal } from '@/utils/livekit' import { useMemo } from 'react' +import { useRaiseHand } from '@/features/rooms/api/updateRaiseHand' type useRaisedHandProps = { participant: Participant @@ -40,18 +41,19 @@ export function useRaisedHand({ participant }: useRaisedHandProps) { const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', { participant, }) + const { raiseHand } = useRaiseHand() const isHandRaised = !!handRaisedAtAttribute const toggleRaisedHand = async () => { if (!isLocal(participant)) return - const localParticipant = participant as LocalParticipant - - const attributes: Record = { - handRaisedAt: !isHandRaised ? new Date().toISOString() : '', + try { + await raiseHand(!isHandRaised) + } catch (e) { + console.error( + `Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}` + ) } - - await localParticipant.setAttributes(attributes) } return { isHandRaised, toggleRaisedHand } diff --git a/src/frontend/src/features/settings/components/tabs/AccountTab.tsx b/src/frontend/src/features/settings/components/tabs/AccountTab.tsx index 0bc8bbd5..2e10862b 100644 --- a/src/frontend/src/features/settings/components/tabs/AccountTab.tsx +++ b/src/frontend/src/features/settings/components/tabs/AccountTab.tsx @@ -8,6 +8,7 @@ import { HStack } from '@/styled-system/jsx' import { useState } from 'react' import { LoginButton } from '@/components/LoginButton' import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices' +import { useRenameParticipant } from '@/features/rooms/api/renameParticipant' export type AccountTabProps = Pick & Pick @@ -17,16 +18,25 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => { const { saveUsername } = usePersistentUserChoices() const room = useRoomContext() const { user, isLoggedIn, logout } = useUser() + + const { renameParticipant } = useRenameParticipant() + const [name, setName] = useState(room?.localParticipant.name ?? '') const userDisplay = user?.full_name && user?.email ? `${user.full_name} (${user.email})` : user?.email - const handleOnSubmit = () => { - if (room) room.localParticipant.setName(name) - saveUsername(name) - if (onOpenChange) onOpenChange(false) + const handleOnSubmit = async () => { + try { + if (room) await renameParticipant(name) + saveUsername(name) + onOpenChange?.(false) // only close on success + } catch (error) { + console.error( + `Failed to rename participant: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } } const handleOnCancel = () => { if (onOpenChange) onOpenChange(false)