mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-30 05:38:59 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f06cbd787a |
@@ -10,37 +10,6 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(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
|
|
||||||
|
|
||||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
|
||||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
|
|
||||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
|
||||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
|
||||||
- ⬆️(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
|
|
||||||
|
|
||||||
- 🐛(transcription) fix silent bug in speaker assignment
|
|
||||||
- 🐛(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
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(backend) allow searching the recording admin table by owner email
|
- ✨(backend) allow searching the recording admin table by owner email
|
||||||
- ✨(frontend) add participant color gradient when camera is off #1490
|
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||||
- ✨(all) allow forcing SSO display name for authenticated users
|
- ✨(all) allow forcing SSO display name for authenticated users
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ echo " - src/backend/pyproject.toml"
|
|||||||
echo " - src/backend/uv.lock"
|
echo " - src/backend/uv.lock"
|
||||||
echo " - src/summary/pyproject.toml"
|
echo " - src/summary/pyproject.toml"
|
||||||
echo " - src/agents/pyproject.toml"
|
echo " - src/agents/pyproject.toml"
|
||||||
echo " - src/agents/uv.lock"
|
|
||||||
echo " - CHANGELOG.md"
|
echo " - CHANGELOG.md"
|
||||||
echo ""
|
echo ""
|
||||||
print_warning "Next steps:"
|
print_warning "Next steps:"
|
||||||
|
|||||||
@@ -344,7 +344,6 @@ These are the environmental options available on meet backend.
|
|||||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
|
||||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
publiccodeYmlVersion: 0.5.0
|
|
||||||
name: LaSuite Meet
|
|
||||||
applicationSuite: LaSuite
|
|
||||||
url: https://github.com/suitenumerique/meet
|
|
||||||
releaseDate: 2026-07-22
|
|
||||||
platforms:
|
|
||||||
- web
|
|
||||||
organisation:
|
|
||||||
name: DINUM
|
|
||||||
uri: https://numerique.gouv.fr
|
|
||||||
fundedBy:
|
|
||||||
- name: Direction interministérielle du numérique (DINUM)
|
|
||||||
uri: https://www.numerique.gouv.fr
|
|
||||||
developmentStatus: stable
|
|
||||||
softwareType: standalone/web
|
|
||||||
intendedAudience:
|
|
||||||
countries:
|
|
||||||
- FR
|
|
||||||
description:
|
|
||||||
en:
|
|
||||||
localisedName: LaSuite Meet
|
|
||||||
shortDescription: "Open Source video conference solution, based on LiveKit"
|
|
||||||
longDescription: "Open Source video conference application, based on LiveKit,
|
|
||||||
Django and React. It is the official web video conference application of
|
|
||||||
French Ministries."
|
|
||||||
features:
|
|
||||||
- Optimized for stability in large meetings (+100 p.)
|
|
||||||
- Support for multiple screen sharing streams
|
|
||||||
- Non-persistent, secure chat
|
|
||||||
- Meeting recording
|
|
||||||
- Meeting transcription & Summary
|
|
||||||
- Telephony integration
|
|
||||||
- Secure participation with robust authentication and access control
|
|
||||||
- Customizable frontend style
|
|
||||||
legal:
|
|
||||||
license: MIT
|
|
||||||
maintenance:
|
|
||||||
type: internal
|
|
||||||
contacts:
|
|
||||||
- name: "Samuel Paccoud"
|
|
||||||
email: samuel.paccoud@numerique.gouv.fr
|
|
||||||
affiliation: DINUM
|
|
||||||
- name: "Antoine Lebaud"
|
|
||||||
email: antoine.lebaud.ext@numerique.gouv.fr
|
|
||||||
affiliation: DINUM
|
|
||||||
localisation:
|
|
||||||
localisationReady: true
|
|
||||||
availableLanguages:
|
|
||||||
- fr
|
|
||||||
- de
|
|
||||||
- en
|
|
||||||
- nl
|
|
||||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9364,9 +9364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.4",
|
"version": "26.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||||
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.4",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.24.0"
|
version = "1.23.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.4",
|
"livekit-agents==1.6.4",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.24.0"
|
version = "1.23.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ from django.http import Http404
|
|||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
from ..services.participants_management import (
|
|
||||||
ParticipantNotFoundException,
|
|
||||||
ParticipantsManagement,
|
|
||||||
ParticipantsManagementException,
|
|
||||||
)
|
|
||||||
|
|
||||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||||
@@ -171,30 +166,3 @@ class CanMuteParticipant(permissions.BasePermission):
|
|||||||
|
|
||||||
# LiveKit token scoped to this room
|
# LiveKit token scoped to this room
|
||||||
return request.auth.video.room == str(obj.id)
|
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
|
|
||||||
|
|||||||
@@ -183,11 +183,13 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
username=username,
|
username=username,
|
||||||
configuration=output["configuration"],
|
configuration=output["configuration"],
|
||||||
role=role,
|
is_admin_or_owner=is_admin_or_owner,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
del output["pin_code"]
|
del output["pin_code"]
|
||||||
|
|
||||||
|
output["is_administrable"] = is_admin_or_owner
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
@@ -297,8 +299,8 @@ class RoomInviteSerializer(serializers.Serializer):
|
|||||||
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
||||||
"""Base serializer for participant management operations."""
|
"""Base serializer for participant management operations."""
|
||||||
|
|
||||||
participant_identity = serializers.CharField(
|
participant_identity = serializers.UUIDField(
|
||||||
help_text="LiveKit participant identity (matching the user's sub format)"
|
help_text="LiveKit participant identity (UUID format)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -310,15 +312,6 @@ 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"]
|
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -88,10 +88,6 @@ from core.services.room_management import (
|
|||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
RoomNotFoundException,
|
RoomNotFoundException,
|
||||||
)
|
)
|
||||||
from core.services.room_roles import (
|
|
||||||
RoomRoleError,
|
|
||||||
RoomRoleService,
|
|
||||||
)
|
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
@@ -643,53 +639,6 @@ class RoomViewSet(
|
|||||||
status=drf_status.HTTP_200_OK,
|
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(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
methods=["post"],
|
methods=["post"],
|
||||||
|
|||||||
@@ -9,11 +9,6 @@ from livekit import api
|
|||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.models import Recording
|
from core.models import Recording
|
||||||
from core.recording.event.notification import notification_service
|
from core.recording.event.notification import notification_service
|
||||||
from core.services.room_management import (
|
|
||||||
RoomManagement,
|
|
||||||
RoomManagementException,
|
|
||||||
RoomNotFoundException,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,15 +39,10 @@ class RecordingEventsService:
|
|||||||
recording_status = status_mapping.get(egress_status)
|
recording_status = status_mapping.get(egress_status)
|
||||||
if recording_status:
|
if recording_status:
|
||||||
try:
|
try:
|
||||||
RoomManagement().update_metadata(
|
utils.update_room_metadata(
|
||||||
room_name, {"recording_status": recording_status}
|
room_name, {"recording_status": recording_status}
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except utils.MetadataUpdateException as e:
|
||||||
logger.info(
|
|
||||||
"LiveKit room %s no longer exists, skipping metadata update",
|
|
||||||
room_name,
|
|
||||||
)
|
|
||||||
except RoomManagementException as e:
|
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -2,12 +2,8 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from core import utils
|
||||||
from core.models import Recording, RecordingStatusChoices
|
from core.models import Recording, RecordingStatusChoices
|
||||||
from core.services.room_management import (
|
|
||||||
RoomManagement,
|
|
||||||
RoomManagementException,
|
|
||||||
RoomNotFoundException,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
RecordingStartError,
|
RecordingStartError,
|
||||||
@@ -68,15 +64,10 @@ class WorkerServiceMediator:
|
|||||||
mode = recording.options.get("original_mode", None) or recording.mode
|
mode = recording.options.get("original_mode", None) or recording.mode
|
||||||
|
|
||||||
try:
|
try:
|
||||||
RoomManagement().update_metadata(
|
utils.update_room_metadata(
|
||||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except utils.MetadataUpdateException as e:
|
||||||
logger.info(
|
|
||||||
"LiveKit room %s no longer exists, skipping metadata update",
|
|
||||||
room_name,
|
|
||||||
)
|
|
||||||
except RoomManagementException as e:
|
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from livekit import api
|
from livekit import api
|
||||||
|
|
||||||
from core import models
|
from core import models, utils
|
||||||
from core.recording.services.metadata_collector import (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
@@ -23,11 +23,6 @@ from core.recording.services.recording_events import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
from .room_management import (
|
|
||||||
RoomManagement,
|
|
||||||
RoomManagementException,
|
|
||||||
RoomNotFoundException,
|
|
||||||
)
|
|
||||||
from .telephony import TelephonyException, TelephonyService
|
from .telephony import TelephonyException, TelephonyService
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -182,15 +177,10 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
RoomManagement().update_metadata(
|
utils.update_room_metadata(
|
||||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
room_name, {}, ["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except utils.MetadataUpdateException as e:
|
||||||
logger.info(
|
|
||||||
"LiveKit room %s no longer exists, skipping metadata update",
|
|
||||||
room_name,
|
|
||||||
)
|
|
||||||
except RoomManagementException as e:
|
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||||
|
|||||||
@@ -104,30 +104,21 @@ class LobbyService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_bypass_lobby(room, user, role) -> bool:
|
def can_bypass_lobby(room, user) -> bool:
|
||||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||||
|
|
||||||
A user can bypass the lobby if:
|
A user can bypass the lobby if:
|
||||||
1. The room is public (open to everyone)
|
1. The room is public (open to everyone)
|
||||||
2. The room has TRUSTED access level and the user is authenticated
|
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.
|
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
|
This function only checks the current state and should be called each time
|
||||||
a participant requests entry to ensure consistent access control, even for
|
a participant requests entry to ensure consistent access control, even for
|
||||||
participants who have already begun waiting.
|
participants who have already begun waiting.
|
||||||
"""
|
"""
|
||||||
return (
|
return room.is_public or (
|
||||||
room.is_public
|
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||||
or (
|
and user.is_authenticated
|
||||||
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(
|
def request_entry(
|
||||||
@@ -153,9 +144,8 @@ class LobbyService:
|
|||||||
participant = self._get_participant(room.id, participant_id)
|
participant = self._get_participant(room.id, participant_id)
|
||||||
|
|
||||||
room_id = str(room.id)
|
room_id = str(room.id)
|
||||||
user_role = room.get_role(request.user)
|
|
||||||
|
|
||||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
if self.can_bypass_lobby(room=room, user=request.user):
|
||||||
if participant is None:
|
if participant is None:
|
||||||
participant = LobbyParticipant(
|
participant = LobbyParticipant(
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
@@ -172,8 +162,8 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
|
is_admin_or_owner=False,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
role=user_role,
|
|
||||||
)
|
)
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|
||||||
@@ -193,8 +183,8 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
|
is_admin_or_owner=False,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
role=user_role,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import (
|
from livekit.api import (
|
||||||
ListRoomsRequest,
|
|
||||||
TwirpError,
|
TwirpError,
|
||||||
UpdateRoomMetadataRequest,
|
UpdateRoomMetadataRequest,
|
||||||
)
|
)
|
||||||
@@ -30,45 +29,20 @@ class RoomManagement:
|
|||||||
"""Service for managing LiveKit rooms."""
|
"""Service for managing LiveKit rooms."""
|
||||||
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def update_metadata(
|
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
||||||
self,
|
"""Update a LiveKit room's metadata.
|
||||||
room_name: str,
|
|
||||||
metadata: Optional[Dict] = None,
|
|
||||||
remove_keys: Optional[list[str]] = None,
|
|
||||||
):
|
|
||||||
"""Merge values into a LiveKit room's metadata.
|
|
||||||
|
|
||||||
The `room_name` corresponds to the LiveKit room identifier
|
The `room_name` corresponds to the LiveKit room identifier
|
||||||
(i.e. the Room model's UUID as a string).
|
(i.e. the Room model's UUID as a string).
|
||||||
|
|
||||||
Raises:
|
|
||||||
RoomNotFoundException: the room does not exist in LiveKit.
|
|
||||||
RoomManagementException: the metadata update otherwise fails.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
lkapi = utils.create_livekit_client()
|
lkapi = utils.create_livekit_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
|
|
||||||
|
|
||||||
if not response.rooms:
|
|
||||||
logger.warning(
|
|
||||||
"Room %s not found in LiveKit, skipping metadata update",
|
|
||||||
room_name,
|
|
||||||
)
|
|
||||||
raise RoomNotFoundException("Room does not exist")
|
|
||||||
|
|
||||||
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
|
|
||||||
|
|
||||||
for key in remove_keys or []:
|
|
||||||
existing_metadata.pop(key, None)
|
|
||||||
|
|
||||||
updated_metadata = {**existing_metadata, **(metadata or {})}
|
|
||||||
|
|
||||||
await lkapi.room.update_room_metadata(
|
await lkapi.room.update_room_metadata(
|
||||||
UpdateRoomMetadataRequest(
|
UpdateRoomMetadataRequest(
|
||||||
room=room_name,
|
room=room_name,
|
||||||
metadata=json.dumps(updated_metadata),
|
metadata=json.dumps(metadata) if metadata is not None else "",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -34,8 +34,10 @@ def mediator(mock_worker_service):
|
|||||||
return WorkerServiceMediator(mock_worker_service)
|
return WorkerServiceMediator(mock_worker_service)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
|
def test_start_recording_success(
|
||||||
|
mock_update_room_metadata, mediator, mock_worker_service
|
||||||
|
):
|
||||||
"""Test successful recording start"""
|
"""Test successful recording start"""
|
||||||
# Setup
|
# Setup
|
||||||
worker_id = "test-worker-123"
|
worker_id = "test-worker-123"
|
||||||
@@ -58,7 +60,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
|||||||
assert mock_recording.worker_id == worker_id
|
assert mock_recording.worker_id == worker_id
|
||||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||||
|
|
||||||
mock_update_metadata.assert_called_once_with(
|
mock_update_room_metadata.assert_called_once_with(
|
||||||
str(mock_recording.room.id),
|
str(mock_recording.room.id),
|
||||||
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
||||||
)
|
)
|
||||||
@@ -67,9 +69,9 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_mediator_start_recording_worker_errors(
|
def test_mediator_start_recording_worker_errors(
|
||||||
mock_update_metadata, mediator, mock_worker_service, error_class
|
mock_update_room_metadata, mediator, mock_worker_service, error_class
|
||||||
):
|
):
|
||||||
"""Test handling of various worker errors during start"""
|
"""Test handling of various worker errors during start"""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -87,7 +89,7 @@ def test_mediator_start_recording_worker_errors(
|
|||||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||||
assert mock_recording.worker_id is None
|
assert mock_recording.worker_id is None
|
||||||
|
|
||||||
mock_update_metadata.assert_not_called()
|
mock_update_room_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -101,9 +103,9 @@ def test_mediator_start_recording_worker_errors(
|
|||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_mediator_start_recording_from_forbidden_status(
|
def test_mediator_start_recording_from_forbidden_status(
|
||||||
mock_update_metadata, mediator, mock_worker_service, status
|
mock_update_room_metadata, mediator, mock_worker_service, status
|
||||||
):
|
):
|
||||||
"""Test handling of various worker errors during start"""
|
"""Test handling of various worker errors during start"""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -117,7 +119,7 @@ def test_mediator_start_recording_from_forbidden_status(
|
|||||||
mock_recording.refresh_from_db()
|
mock_recording.refresh_from_db()
|
||||||
assert mock_recording.status == status
|
assert mock_recording.status == status
|
||||||
|
|
||||||
mock_update_metadata.assert_not_called()
|
mock_update_room_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
|||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user)
|
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
|||||||
other_room = RoomFactory()
|
other_room = RoomFactory()
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(other_room.id), user)
|
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||||
response = client.post(
|
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})
|
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user)
|
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
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
|
# Token identity matches the admin user so LiveKitTokenAuthentication
|
||||||
# resolves request.user back to the admin.
|
# resolves request.user back to the admin.
|
||||||
token = utils.generate_token(str(room.id), user)
|
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
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
|
# Token is scoped to a DIFFERENT room, and admin status must only be
|
||||||
# honored when established via session, never via a LiveKit
|
# honored when established via session, never via a LiveKit
|
||||||
# token, which can be replayed off-host.
|
# token, which can be replayed off-host.
|
||||||
token = utils.generate_token(str(other_room.id), user)
|
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
|||||||
role=random.choice(["administrator", "owner"]),
|
role=random.choice(["administrator", "owner"]),
|
||||||
)
|
)
|
||||||
# The token is the only credential.
|
# The token is the only credential.
|
||||||
token = utils.generate_token(str(room.id), admin_user)
|
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
|||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user)
|
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user)
|
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user)
|
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user)
|
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
|
|||||||
)
|
)
|
||||||
client.force_authenticate(user=user)
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
payload = {"participant_identity": ["test"]}
|
payload = {"participant_identity": "invalid-uuid"}
|
||||||
|
|
||||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(url, payload, format="json")
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert "Not a valid string." in str(response.data)
|
assert "Must be a valid UUID." in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
def test_update_participant_no_update_fields():
|
def test_update_participant_no_update_fields():
|
||||||
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
|
|||||||
)
|
)
|
||||||
client.force_authenticate(user=user)
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
payload = {"participant_identity": ["invalid-uuid"]}
|
payload = {"participant_identity": "invalid-uuid"}
|
||||||
|
|
||||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(url, payload, format="json")
|
response = client.post(url, payload, format="json")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import pytest
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from ...models import RoleChoices, RoomAccessLevel
|
from ...models import RoomAccessLevel
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "trusted",
|
"access_level": "trusted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -68,6 +70,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -84,6 +87,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -100,6 +104,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -209,6 +214,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -255,6 +261,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -271,7 +278,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=["camera"],
|
sources=["camera"],
|
||||||
role=None,
|
is_admin_or_owner=False,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -306,6 +313,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -322,7 +330,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=None,
|
sources=None,
|
||||||
role=None,
|
is_admin_or_owner=False,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -347,6 +355,7 @@ def test_api_rooms_retrieve_authenticated():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": False,
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -392,6 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -408,7 +418,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=["camera"],
|
sources=["camera"],
|
||||||
role=str(RoleChoices.MEMBER),
|
is_admin_or_owner=False,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -483,6 +493,7 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
assert content_dict == {
|
assert content_dict == {
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
|
"is_administrable": True,
|
||||||
"configuration": {},
|
"configuration": {},
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
@@ -500,6 +511,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=None,
|
sources=None,
|
||||||
role=str(user_access.role),
|
is_admin_or_owner=True,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -20,9 +20,8 @@ from core.services.livekit_events import (
|
|||||||
api,
|
api,
|
||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
from core.services.room_management import RoomManagementException
|
|
||||||
from core.services.telephony import TelephonyException, TelephonyService
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
from core.utils import NotificationError
|
from core.utils import MetadataUpdateException, NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -71,9 +70,9 @@ def test_initialization(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_ended_success(
|
def test_handle_egress_ended_success(
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording and notifies all participant."""
|
"""Should successfully stop recording and notifies all participant."""
|
||||||
|
|
||||||
@@ -87,8 +86,8 @@ def test_handle_egress_ended_success(
|
|||||||
mock_notify.assert_called_once_with(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
)
|
)
|
||||||
mock_update_metadata.assert_called_once_with(
|
mock_update_room_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
@@ -105,9 +104,9 @@ def test_handle_egress_ended_success(
|
|||||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_updated_success(
|
def test_handle_egress_updated_success(
|
||||||
mock_update_metadata, egress_status, status, service
|
mock_update_room_metadata, egress_status, status, service
|
||||||
):
|
):
|
||||||
"""Should successfully update room's metadata."""
|
"""Should successfully update room's metadata."""
|
||||||
|
|
||||||
@@ -118,7 +117,7 @@ def test_handle_egress_updated_success(
|
|||||||
|
|
||||||
service._handle_egress_updated(mock_data)
|
service._handle_egress_updated(mock_data)
|
||||||
|
|
||||||
mock_update_metadata.assert_called_once_with(
|
mock_update_room_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {"recording_status": status}
|
str(recording.room.id), {"recording_status": status}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,9 +129,9 @@ def test_handle_egress_updated_success(
|
|||||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_updated_non_handled(
|
def test_handle_egress_updated_non_handled(
|
||||||
mock_update_metadata, egress_status, service
|
mock_update_room_metadata, egress_status, service
|
||||||
):
|
):
|
||||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||||
|
|
||||||
@@ -143,7 +142,7 @@ def test_handle_egress_updated_non_handled(
|
|||||||
|
|
||||||
service._handle_egress_updated(mock_data)
|
service._handle_egress_updated(mock_data)
|
||||||
|
|
||||||
mock_update_metadata.assert_not_called()
|
mock_update_room_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -154,9 +153,9 @@ def test_handle_egress_updated_non_handled(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_ended_metadata_update_fails(
|
def test_handle_egress_ended_metadata_update_fails(
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop and save recording when metadata's update fails."""
|
"""Should successfully stop and save recording when metadata's update fails."""
|
||||||
|
|
||||||
@@ -165,7 +164,7 @@ def test_handle_egress_ended_metadata_update_fails(
|
|||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||||
|
|
||||||
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
|
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
|
||||||
|
|
||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
@@ -179,9 +178,9 @@ def test_handle_egress_ended_metadata_update_fails(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_ended_notification_fails(
|
def test_handle_egress_ended_notification_fails(
|
||||||
mock_update_metadata, mock_notify, service
|
mock_update_room_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||||
|
|
||||||
@@ -201,15 +200,15 @@ def test_handle_egress_ended_notification_fails(
|
|||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
mock_update_metadata.assert_called_once_with(
|
mock_update_room_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_ended_recording_not_found(
|
def test_handle_egress_ended_recording_not_found(
|
||||||
mock_update_metadata, mock_notify, service
|
mock_update_room_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||||
|
|
||||||
@@ -224,16 +223,16 @@ def test_handle_egress_ended_recording_not_found(
|
|||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_metadata.assert_not_called()
|
mock_update_room_metadata.assert_not_called()
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "active"
|
assert recording.status == "active"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_ended_recording_not_active(
|
def test_handle_egress_ended_recording_not_active(
|
||||||
mock_update_metadata, mock_notify, service
|
mock_update_room_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should ignore non-active recordings."""
|
"""Should ignore non-active recordings."""
|
||||||
|
|
||||||
@@ -245,8 +244,8 @@ def test_handle_egress_ended_recording_not_active(
|
|||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_metadata.assert_called_once_with(
|
mock_update_room_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
@@ -254,9 +253,9 @@ def test_handle_egress_ended_recording_not_active(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_ended_recording_not_limit_reached(
|
def test_handle_egress_ended_recording_not_limit_reached(
|
||||||
mock_update_metadata, mock_notify, service
|
mock_update_room_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should ignore egress non-limit-reached statuses."""
|
"""Should ignore egress non-limit-reached statuses."""
|
||||||
|
|
||||||
@@ -268,16 +267,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
|||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_metadata.assert_called_once_with(
|
mock_update_room_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
||||||
mock_update_metadata, mock_collector_class, service, settings
|
mock_update_room_metadata, mock_collector_class, service, settings
|
||||||
):
|
):
|
||||||
"""Should call MetadataCollectorService.stop when it exists."""
|
"""Should call MetadataCollectorService.stop when it exists."""
|
||||||
settings.METADATA_COLLECTOR_ENABLED = True
|
settings.METADATA_COLLECTOR_ENABLED = True
|
||||||
@@ -307,7 +306,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
@@ -336,7 +335,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
|||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"egress_status",
|
"egress_status",
|
||||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||||
@@ -346,7 +345,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
|||||||
[(True, "notification_succeeded"), (False, "saved")],
|
[(True, "notification_succeeded"), (False, "saved")],
|
||||||
)
|
)
|
||||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||||
mock_update_metadata,
|
mock_update_room_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
notify_return_value,
|
notify_return_value,
|
||||||
@@ -379,7 +378,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
|||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"egress_status, expected_status",
|
"egress_status, expected_status",
|
||||||
[
|
[
|
||||||
@@ -388,7 +387,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||||
mock_update_metadata,
|
mock_update_room_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
egress_status,
|
egress_status,
|
||||||
@@ -425,9 +424,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
|
|||||||
EgressStatus.EGRESS_ABORTED,
|
EgressStatus.EGRESS_ABORTED,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||||
mock_update_metadata, egress_status, service, settings
|
mock_update_room_metadata, egress_status, service, settings
|
||||||
):
|
):
|
||||||
"""Shouldn't save on invalid status."""
|
"""Shouldn't save on invalid status."""
|
||||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
@@ -446,9 +445,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||||
mock_update_metadata, status, service, settings
|
mock_update_room_metadata, status, service, settings
|
||||||
):
|
):
|
||||||
"""Should handle non-savable recordings idempotently without raising.
|
"""Should handle non-savable recordings idempotently without raising.
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,13 @@ import uuid
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from core.factories import RoomFactory
|
||||||
from core.models import RoleChoices, RoomAccessLevel
|
from core.models import RoomAccessLevel
|
||||||
from core.services.lobby import (
|
from core.services.lobby import (
|
||||||
LobbyParticipant,
|
LobbyParticipant,
|
||||||
LobbyParticipantNotFound,
|
LobbyParticipantNotFound,
|
||||||
@@ -189,17 +188,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
|
|||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_public_room(lobby_service):
|
def test_can_bypass_lobby_public_room(lobby_service):
|
||||||
"""Should return True for public rooms regardless of user auth and role."""
|
"""Should return True for public rooms regardless of user auth."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
|
||||||
# Anonymous user
|
# Anonymous user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = False
|
user.is_authenticated = False
|
||||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||||
|
|
||||||
# Authenticated user
|
# Authenticated user
|
||||||
user.is_authenticated = True
|
user.is_authenticated = True
|
||||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||||
@@ -209,7 +208,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
|||||||
# Authenticated user
|
# Authenticated user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = True
|
user.is_authenticated = True
|
||||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||||
@@ -219,34 +218,21 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
|||||||
# Anonymous user
|
# Anonymous user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = False
|
user.is_authenticated = False
|
||||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_private_room(lobby_service):
|
def test_can_bypass_lobby_private_room(lobby_service):
|
||||||
"""Should return False for private rooms regardless of user auth if role is not."""
|
"""Should return False for private rooms regardless of user auth."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
# Anonymous user
|
# Anonymous user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = False
|
user.is_authenticated = False
|
||||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||||
|
|
||||||
# Authenticated user
|
# Authenticated user
|
||||||
user.is_authenticated = True
|
user.is_authenticated = True
|
||||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
assert lobby_service.can_bypass_lobby(room, user) 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")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
@@ -255,7 +241,7 @@ def test_request_entry_public_room(
|
|||||||
):
|
):
|
||||||
"""Test requesting entry to a public room."""
|
"""Test requesting entry to a public room."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = AnonymousUser()
|
request.user = mock.Mock()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
|
||||||
@@ -280,8 +266,8 @@ def test_request_entry_public_room(
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
|
is_admin_or_owner=False,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
@@ -293,7 +279,8 @@ def test_request_entry_trusted_room(
|
|||||||
):
|
):
|
||||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = UserFactory()
|
request.user = mock.Mock()
|
||||||
|
request.user.is_authenticated = True
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
|
||||||
@@ -318,8 +305,8 @@ def test_request_entry_trusted_room(
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
|
is_admin_or_owner=False,
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
role=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
@@ -332,7 +319,6 @@ def test_request_entry_new_participant(
|
|||||||
"""Test requesting entry for a new participant."""
|
"""Test requesting entry for a new participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
request.user = AnonymousUser()
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -362,7 +348,6 @@ def test_request_entry_waiting_participant(
|
|||||||
"""Test requesting entry for a waiting participant."""
|
"""Test requesting entry for a waiting participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
request.user = AnonymousUser()
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -389,7 +374,7 @@ def test_request_entry_accepted_participant(
|
|||||||
):
|
):
|
||||||
"""Test requesting entry for an accepted participant."""
|
"""Test requesting entry for an accepted participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = AnonymousUser()
|
request.user = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -415,48 +400,8 @@ def test_request_entry_accepted_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
|
is_admin_or_owner=False,
|
||||||
participant_id="test-participant-id",
|
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)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|||||||
+58
-11
@@ -31,6 +31,7 @@ from livekit.api import ( # pylint: disable=E0611
|
|||||||
LiveKitAPI,
|
LiveKitAPI,
|
||||||
SendDataRequest,
|
SendDataRequest,
|
||||||
TwirpError,
|
TwirpError,
|
||||||
|
UpdateRoomMetadataRequest,
|
||||||
VideoGrants,
|
VideoGrants,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ def generate_token(
|
|||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
color: Optional[str] = None,
|
color: Optional[str] = None,
|
||||||
sources: Optional[List[str]] = None,
|
sources: Optional[List[str]] = None,
|
||||||
role: Optional[str] = None,
|
is_admin_or_owner: bool = False,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a LiveKit access token for a user in a specific room.
|
"""Generate a LiveKit access token for a user in a specific room.
|
||||||
@@ -79,7 +80,7 @@ def generate_token(
|
|||||||
If none, a value will be generated
|
If none, a value will be generated
|
||||||
sources: (Optional[List[str]]): List of media sources the user can publish
|
sources: (Optional[List[str]]): List of media sources the user can publish
|
||||||
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
|
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
|
||||||
role (Optional[str]): Room's access role if any
|
is_admin_or_owner (bool): Whether user has admin privileges
|
||||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||||
used as identity when user.is_anonymous.
|
used as identity when user.is_anonymous.
|
||||||
|
|
||||||
@@ -87,7 +88,6 @@ def generate_token(
|
|||||||
str: The LiveKit JWT access token.
|
str: The LiveKit JWT access token.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
is_admin_or_owner = role in ("owner", "administrator")
|
|
||||||
if is_admin_or_owner:
|
if is_admin_or_owner:
|
||||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||||
|
|
||||||
@@ -128,11 +128,7 @@ def generate_token(
|
|||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(display_name)
|
.with_name(display_name)
|
||||||
.with_attributes(
|
.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",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,7 +139,7 @@ def generate_livekit_config(
|
|||||||
room_id: str,
|
room_id: str,
|
||||||
user,
|
user,
|
||||||
username: str,
|
username: str,
|
||||||
role: Optional[str] = None,
|
is_admin_or_owner: bool,
|
||||||
color: Optional[str] = None,
|
color: Optional[str] = None,
|
||||||
configuration: Optional[dict] = None,
|
configuration: Optional[dict] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
@@ -154,7 +150,7 @@ def generate_livekit_config(
|
|||||||
room_id: Room identifier
|
room_id: Room identifier
|
||||||
user: User instance requesting access
|
user: User instance requesting access
|
||||||
username: Display name in room
|
username: Display name in room
|
||||||
role (str): Room's access role if any
|
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
|
||||||
color (Optional[str]): Optional color to associate with the participant.
|
color (Optional[str]): Optional color to associate with the participant.
|
||||||
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
||||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||||
@@ -177,7 +173,7 @@ def generate_livekit_config(
|
|||||||
username=username,
|
username=username,
|
||||||
color=color,
|
color=color,
|
||||||
sources=sources,
|
sources=sources,
|
||||||
role=role,
|
is_admin_or_owner=is_admin_or_owner,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -262,6 +258,57 @@ async def notify_participants(room_name: str, notification_data: dict):
|
|||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataUpdateException(Exception):
|
||||||
|
"""Room's metadata update fails."""
|
||||||
|
|
||||||
|
|
||||||
|
@async_to_sync
|
||||||
|
async def update_room_metadata(
|
||||||
|
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
|
||||||
|
):
|
||||||
|
"""Update LiveKit room metadata by merging new values with existing metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_name: Name of the room to update
|
||||||
|
metadata: Dictionary of metadata key-values to add/update
|
||||||
|
remove_keys: Optional list of keys to remove from existing metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lkapi = create_livekit_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await lkapi.room.list_rooms(
|
||||||
|
ListRoomsRequest(
|
||||||
|
names=[room_name],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.rooms:
|
||||||
|
return
|
||||||
|
|
||||||
|
room = response.rooms[0]
|
||||||
|
|
||||||
|
existing_metadata = json.loads(room.metadata) if room.metadata else {}
|
||||||
|
|
||||||
|
if remove_keys:
|
||||||
|
for key in remove_keys:
|
||||||
|
existing_metadata.pop(key, None)
|
||||||
|
|
||||||
|
updated_metadata = {**existing_metadata, **metadata}
|
||||||
|
|
||||||
|
await lkapi.room.update_room_metadata(
|
||||||
|
UpdateRoomMetadataRequest(
|
||||||
|
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except TwirpError as e:
|
||||||
|
raise MetadataUpdateException(
|
||||||
|
f"Failed to update metadata for room {room_name}: {e}"
|
||||||
|
) from e
|
||||||
|
finally:
|
||||||
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
|
||||||
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -398,9 +398,6 @@ class Base(Configuration):
|
|||||||
"feedback": values.DictValue(
|
"feedback": values.DictValue(
|
||||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||||
),
|
),
|
||||||
"documentation_url": values.Value(
|
|
||||||
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
|
||||||
),
|
|
||||||
"external_home_url": values.Value(
|
"external_home_url": values.Value(
|
||||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.24.0"
|
version = "1.23.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.24.0"
|
version = "1.23.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
Generated
+26
-32
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.24.0",
|
"version": "1.23.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.24.0",
|
"version": "1.23.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
@@ -15,11 +15,11 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@mediapipe/tasks-vision": "0.10.35",
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.20.0",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.395.0",
|
"posthog-js": "1.391.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
@@ -1028,9 +1028,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/protocol": {
|
"node_modules/@livekit/protocol": {
|
||||||
"version": "1.46.6",
|
"version": "1.45.8",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
||||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
@@ -1049,18 +1049,12 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
"node_modules/@mediapipe/tasks-vision": {
|
||||||
"version": "0.10.14",
|
"version": "0.10.14",
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@mediapipe/tasks-vision": {
|
|
||||||
"version": "0.10.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
|
||||||
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -2421,9 +2415,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.101.1",
|
"version": "5.101.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||||
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2442,12 +2436,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.101.1",
|
"version": "5.101.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||||
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.101.1"
|
"@tanstack/query-core": "5.101.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -8125,13 +8119,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.20.0",
|
"version": "2.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
"@livekit/protocol": "1.46.6",
|
"@livekit/protocol": "1.45.8",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
@@ -9136,13 +9130,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.395.0",
|
"version": "1.391.2",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.391.2.tgz",
|
||||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
"integrity": "sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.38.0",
|
"@posthog/core": "^1.35.3",
|
||||||
"@posthog/types": "^1.391.1",
|
"@posthog/types": "^1.390.2",
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.24.0",
|
"version": "1.23.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@mediapipe/tasks-vision": "0.10.35",
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.0",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.20.0",
|
"livekit-client": "2.19.2",
|
||||||
"posthog-js": "1.395.0",
|
"posthog-js": "1.391.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
|
|||||||
@@ -114,10 +114,6 @@ const config: Config = {
|
|||||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
overlayIn: {
|
|
||||||
from: { opacity: 0 },
|
|
||||||
to: { opacity: 0.6 },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export interface ApiConfig {
|
|||||||
feedback: {
|
feedback: {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
documentation_url?: string
|
|
||||||
external_home_url?: string
|
external_home_url?: string
|
||||||
silence_livekit_debug_logs?: boolean
|
silence_livekit_debug_logs?: boolean
|
||||||
is_silent_login_enabled?: boolean
|
is_silent_login_enabled?: boolean
|
||||||
|
|||||||
@@ -7,20 +7,36 @@ const avatar = cva({
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
context: {
|
context: {
|
||||||
subtitles: { width: '40px', height: '40px' },
|
subtitles: {
|
||||||
list: { width: '32px', height: '32px' },
|
width: '40px',
|
||||||
placeholder: { width: '100%', height: '100%' },
|
height: '40px',
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
lineHeight: '1rem',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
lineHeight: '1rem',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
true: { border: '2px solid white' },
|
true: {
|
||||||
|
border: '2px solid white',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -28,49 +44,37 @@ 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> & {
|
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
name?: string
|
name?: string
|
||||||
bgColor?: string
|
bgColor?: string
|
||||||
} & RecipeVariantProps<typeof avatar>
|
} & RecipeVariantProps<typeof avatar>
|
||||||
|
|
||||||
export const Avatar = React.memo(
|
export const Avatar = ({
|
||||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
name,
|
||||||
const initials = getInitials(name)
|
bgColor,
|
||||||
return (
|
context,
|
||||||
<div
|
notification,
|
||||||
style={{ backgroundColor: bgColor, ...style }}
|
style,
|
||||||
className={avatar({ context, notification })}
|
...props
|
||||||
{...props}
|
}: AvatarProps) => {
|
||||||
|
const initial = name?.trim()?.charAt(0) ?? ''
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
className={avatar({ context, notification })}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={css({
|
||||||
|
marginTop: '-0.3rem',
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<svg
|
{initial}
|
||||||
viewBox="0 0 100 100"
|
</span>
|
||||||
aria-hidden="true"
|
</div>
|
||||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
)
|
||||||
>
|
}
|
||||||
<text
|
|
||||||
x="50"
|
|
||||||
y="50"
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="central"
|
|
||||||
fontSize={initials.length > 1 ? 48 : 52}
|
|
||||||
fontWeight="500"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
{initials.toUpperCase()}
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Avatar.displayName = 'Avatar'
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
import { ChatMessages } from './ChatMessages'
|
|
||||||
import { ChatTextArea } from './ChatTextArea'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
|
|
||||||
const ChatContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
padding: '0 1.5rem',
|
|
||||||
flexGrow: 1,
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ChatMessagesContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
flexGrow: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const TextContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
padding: '0.75rem',
|
|
||||||
backgroundColor: 'greyscale.50',
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: '0.75rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Chat = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatContainer>
|
|
||||||
<TextContainer>
|
|
||||||
<Text variant="sm">{t('disclaimer')}</Text>
|
|
||||||
</TextContainer>
|
|
||||||
<ChatMessagesContainer>
|
|
||||||
<ChatMessages />
|
|
||||||
</ChatMessagesContainer>
|
|
||||||
<ChatTextArea />
|
|
||||||
</ChatContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { ChatRow } from '@/stores/chat'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { ChatMessageMetadata } from './ChatMessageMedata'
|
|
||||||
import { ChatMessageBody } from './ChatMessageBody'
|
|
||||||
|
|
||||||
const StyledContainer = styled('li', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.25rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ChatMessageProps = {
|
|
||||||
item: ChatRow
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatMessage = ({ item }: ChatMessageProps) => {
|
|
||||||
const time = new Date(item.timestamp)
|
|
||||||
const locale = navigator ? navigator.language : 'en-US'
|
|
||||||
return (
|
|
||||||
<StyledContainer
|
|
||||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
|
||||||
>
|
|
||||||
{!item.hideMetadata && (
|
|
||||||
<ChatMessageMetadata
|
|
||||||
timestamp={item.timestamp}
|
|
||||||
identity={item.identity}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ChatMessageBody message={item.message} />
|
|
||||||
</StyledContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { ChatRow } from '@/stores/chat'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
import { formatChatMessageLinks } from '@livekit/components-react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
|
|
||||||
type ChatMessageBodyProps = Pick<ChatRow, 'message'>
|
|
||||||
|
|
||||||
export const ChatMessageBody = React.memo(
|
|
||||||
({ message }: ChatMessageBodyProps) => {
|
|
||||||
const formattedMessage = useMemo(() => {
|
|
||||||
return formatChatMessageLinks(message)
|
|
||||||
}, [message])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
variant="sm"
|
|
||||||
margin={false}
|
|
||||||
className={css({
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
'& .lk-chat-link': {
|
|
||||||
color: 'blue',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{formattedMessage}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatMessageBody.displayName = 'ChatMessageBody'
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { ChatRow, chatStore } from '@/stores/chat'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
|
|
||||||
const StyledContainer = styled('span', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
paddingTop: '0.75rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ChatMessageMetadataProps = Pick<ChatRow, 'identity' | 'timestamp'>
|
|
||||||
|
|
||||||
export const ChatMessageMetadata = React.memo(
|
|
||||||
({ identity, timestamp }: ChatMessageMetadataProps) => {
|
|
||||||
const time = new Date(timestamp)
|
|
||||||
const locale = navigator ? navigator.language : 'en-US'
|
|
||||||
|
|
||||||
const { names } = useSnapshot(chatStore)
|
|
||||||
|
|
||||||
const currentDisplayName = useMemo(() => {
|
|
||||||
if (identity) return names[identity] ?? identity
|
|
||||||
}, [names, identity])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledContainer>
|
|
||||||
<Text bold={true} variant="sm">
|
|
||||||
{currentDisplayName}
|
|
||||||
</Text>
|
|
||||||
<Text variant="smNote" wrap="no">
|
|
||||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
|
||||||
</Text>
|
|
||||||
</StyledContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import {
|
|
||||||
ListBox,
|
|
||||||
ListBoxItem,
|
|
||||||
ListLayout,
|
|
||||||
Virtualizer,
|
|
||||||
} from 'react-aria-components'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ChatMessage } from './ChatMessage.tsx'
|
|
||||||
import { useLayoutEffect, useRef } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { ChatRow, chatStore } from '@/stores/chat'
|
|
||||||
|
|
||||||
// Estimated height of a chat entry in px. ListLayout measures the real
|
|
||||||
// rendered height of each row; this value is only used to size the
|
|
||||||
// scrollbar before rows have been measured.
|
|
||||||
const ESTIMATED_ROW_HEIGHT = 56
|
|
||||||
const BOTTOM_THRESHOLD_PX = 32
|
|
||||||
|
|
||||||
export const ChatMessages = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
|
||||||
const { rows: items } = useSnapshot(chatStore)
|
|
||||||
|
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
|
||||||
const stick = useRef(true)
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = listRef.current
|
|
||||||
if (!el) return
|
|
||||||
const pin = () => {
|
|
||||||
if (stick.current) el.scrollTop = el.scrollHeight
|
|
||||||
}
|
|
||||||
const onScroll = () => {
|
|
||||||
stick.current =
|
|
||||||
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX
|
|
||||||
}
|
|
||||||
const ro = new ResizeObserver(pin)
|
|
||||||
// Sizer div = ListLayout's content size. Fires on append, and again each
|
|
||||||
// time an estimated row height is replaced by a measured one.
|
|
||||||
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
|
||||||
// Container. Height goes 0 -> N as the panel animates in, and on resize.
|
|
||||||
ro.observe(el)
|
|
||||||
el.addEventListener('scroll', onScroll, { passive: true })
|
|
||||||
pin()
|
|
||||||
return () => {
|
|
||||||
ro.disconnect()
|
|
||||||
el.removeEventListener('scroll', onScroll)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Virtualizer
|
|
||||||
layout={ListLayout}
|
|
||||||
layoutOptions={{
|
|
||||||
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
|
|
||||||
gap: 4,
|
|
||||||
padding: 4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListBox
|
|
||||||
ref={listRef}
|
|
||||||
aria-label={t('messagesLabel', 'Chat messages')}
|
|
||||||
items={items}
|
|
||||||
selectionMode="none"
|
|
||||||
style={{
|
|
||||||
display: 'block',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(item: ChatRow) => (
|
|
||||||
<ListBoxItem style={{ width: '100%' }}>
|
|
||||||
<ChatMessage item={item} />
|
|
||||||
</ListBoxItem>
|
|
||||||
)}
|
|
||||||
</ListBox>
|
|
||||||
</Virtualizer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level
|
|
||||||
import { ref } from 'valtio'
|
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
import React, { useEffect } from 'react'
|
|
||||||
import { useChat, useRoomContext } from '@livekit/components-react'
|
|
||||||
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
|
|
||||||
import type { ChatMessage } from '@livekit/components-core'
|
|
||||||
import {
|
|
||||||
LocalParticipant,
|
|
||||||
Participant,
|
|
||||||
RemoteParticipant,
|
|
||||||
RoomEvent,
|
|
||||||
} from 'livekit-client'
|
|
||||||
|
|
||||||
export const ChatProvider = () => {
|
|
||||||
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
|
||||||
const { send, chatMessages, isSending } = useChat()
|
|
||||||
const { isChatOpen } = useSidePanel()
|
|
||||||
|
|
||||||
const room = useRoomContext()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
resetChatStore()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Tigger the message notification (temporary)
|
|
||||||
useEffect(() => {
|
|
||||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
|
||||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
|
||||||
// (see https://github.com/livekit/components-js/issues/1158)
|
|
||||||
// Remove this once we refactor chat to use the new text stream approach
|
|
||||||
const latestMessage = chatMessages.slice(-1)[0]
|
|
||||||
if (!latestMessage) return
|
|
||||||
const from = latestMessage.from as
|
|
||||||
| RemoteParticipant
|
|
||||||
| LocalParticipant
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
room.emit(RoomEvent.ChatMessage, latestMessage, from)
|
|
||||||
}, [chatMessages, room])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
for (let i = chatStore.rows.length; i < chatMessages.length; i++) {
|
|
||||||
appendRow(chatMessages[i])
|
|
||||||
}
|
|
||||||
}, [chatMessages])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
chatStore.send = ref(send)
|
|
||||||
}, [send])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
chatStore.isSending = isSending
|
|
||||||
}, [isSending])
|
|
||||||
|
|
||||||
// Set the unread messages count
|
|
||||||
useEffect(() => {
|
|
||||||
if (chatMessages.length === 0) return
|
|
||||||
const last = chatMessages[chatMessages.length - 1]
|
|
||||||
if (isChatOpen) {
|
|
||||||
lastReadMsgAt.current = last.timestamp
|
|
||||||
chatStore.unreadMessages = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatStore.unreadMessages = chatMessages.filter(
|
|
||||||
(m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current
|
|
||||||
).length
|
|
||||||
}, [chatMessages, isChatOpen])
|
|
||||||
|
|
||||||
// Listen to participant name changes
|
|
||||||
useEffect(() => {
|
|
||||||
const setName = (p: Participant) => {
|
|
||||||
chatStore.names[p.identity] = p.name || p.identity
|
|
||||||
}
|
|
||||||
const onNameChanged = (_name: string, p: Participant) => setName(p)
|
|
||||||
room.on(RoomEvent.ParticipantNameChanged, onNameChanged)
|
|
||||||
return () => {
|
|
||||||
room.off(RoomEvent.ParticipantNameChanged, onNameChanged)
|
|
||||||
}
|
|
||||||
}, [room])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
|
||||||
|
|
||||||
type ChatSubmitButtonProps = {
|
|
||||||
handleSubmit: () => Promise<void>
|
|
||||||
isDisabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatSubmitButton = React.memo(
|
|
||||||
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
square
|
|
||||||
invisible
|
|
||||||
variant="tertiaryText"
|
|
||||||
size="sm"
|
|
||||||
onPress={handleSubmit}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={t('button.label')}
|
|
||||||
>
|
|
||||||
<RiSendPlane2Fill />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ChatSubmitButton.displayName = 'ChatSubmitButton'
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { TextArea } from '@/primitives'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import React, { useCallback, useEffect } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import {
|
|
||||||
chatStore,
|
|
||||||
clearTextAreaValue,
|
|
||||||
persistTextAreaValue,
|
|
||||||
} from '@/stores/chat'
|
|
||||||
import { ChatSubmitButton } from './ChatSubmitButton'
|
|
||||||
|
|
||||||
const StyledContainer = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
margin: '0.75rem 0 1.5rem',
|
|
||||||
padding: '0.5rem',
|
|
||||||
backgroundColor: 'gray.100',
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ChatTextArea = () => {
|
|
||||||
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
|
||||||
|
|
||||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = inputRef.current
|
|
||||||
if (!el) return
|
|
||||||
const raf = requestAnimationFrame(() => {
|
|
||||||
el.focus({ preventScroll: true })
|
|
||||||
const end = el.value.length
|
|
||||||
el.setSelectionRange(end, end)
|
|
||||||
})
|
|
||||||
return () => cancelAnimationFrame(raf)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
const text = chatStore.textAreaValue
|
|
||||||
if (!send || !text) return
|
|
||||||
await send(text)
|
|
||||||
inputRef?.current?.focus({ preventScroll: true })
|
|
||||||
clearTextAreaValue()
|
|
||||||
}, [send, inputRef])
|
|
||||||
|
|
||||||
const isDisabled = !textAreaValue.trim() || isSending
|
|
||||||
|
|
||||||
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
|
||||||
return
|
|
||||||
e.preventDefault()
|
|
||||||
await handleSubmit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledContainer>
|
|
||||||
<TextArea
|
|
||||||
ref={inputRef}
|
|
||||||
value={textAreaValue}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onKeyUp={onKeyUp}
|
|
||||||
onChange={(e) => {
|
|
||||||
persistTextAreaValue(e.target.value)
|
|
||||||
}}
|
|
||||||
fieldSizing={'content'}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
resize: 'none',
|
|
||||||
height: 'auto',
|
|
||||||
maxHeight: '240px',
|
|
||||||
minHeight: `34px`,
|
|
||||||
lineHeight: 1.25,
|
|
||||||
padding: '7px 10px',
|
|
||||||
}}
|
|
||||||
placeholderStyle="strong"
|
|
||||||
spellCheck={false}
|
|
||||||
maxLength={2000}
|
|
||||||
placeholder={t('textArea.placeholder')}
|
|
||||||
aria-label={t('textArea.label')}
|
|
||||||
/>
|
|
||||||
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
|
|
||||||
</StyledContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
|
||||||
|
|
||||||
const MIN_HEIGHT = 130
|
const MIN_HEIGHT = 130
|
||||||
const MIN_WIDTH = 140
|
const MIN_WIDTH = 140
|
||||||
@@ -11,72 +10,6 @@ const MIN_VISIBLE_TILES = 1
|
|||||||
const ASPECT_RATIO = 16 / 10
|
const ASPECT_RATIO = 16 / 10
|
||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
type CarouselOrientation = 'vertical' | 'horizontal'
|
|
||||||
|
|
||||||
interface CarouselLayoutState {
|
|
||||||
orientation: CarouselOrientation
|
|
||||||
maxVisibleTiles: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CarouselLayoutObserverProps {
|
|
||||||
asideEl: React.RefObject<HTMLDivElement>
|
|
||||||
orientation?: CarouselOrientation
|
|
||||||
onLayoutChange: (layout: CarouselLayoutState) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const CarouselLayoutObserver = ({
|
|
||||||
orientation,
|
|
||||||
asideEl,
|
|
||||||
onLayoutChange,
|
|
||||||
}: CarouselLayoutObserverProps) => {
|
|
||||||
const { width, height } = useSize(asideEl)
|
|
||||||
|
|
||||||
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
|
||||||
// container size hovers around a breakpoint. A ref (not state) because
|
|
||||||
// updating it must not trigger a re-render.
|
|
||||||
const prevTilesRef = React.useRef(0)
|
|
||||||
|
|
||||||
const carouselOrientation: CarouselOrientation =
|
|
||||||
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
|
||||||
|
|
||||||
const tileSpan =
|
|
||||||
carouselOrientation === 'vertical'
|
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
|
||||||
|
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const availableSpan =
|
|
||||||
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
|
||||||
|
|
||||||
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
|
||||||
|
|
||||||
let maxVisibleTiles: number
|
|
||||||
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
|
||||||
// Within the dead zone: keep the previous count.
|
|
||||||
maxVisibleTiles = Math.round(prevTilesRef.current)
|
|
||||||
} else {
|
|
||||||
maxVisibleTiles = Math.round(tilesThatFit)
|
|
||||||
prevTilesRef.current = tilesThatFit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply cosmetic layout output straight to the DOM.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = asideEl.current
|
|
||||||
if (!el) return
|
|
||||||
el.dataset.lkOrientation = carouselOrientation
|
|
||||||
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
|
||||||
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
|
||||||
|
|
||||||
// Report upward only what the parent actually needs for
|
|
||||||
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
|
||||||
useEffect(() => {
|
|
||||||
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
|
||||||
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
@@ -107,42 +40,52 @@ export function CarouselLayout({
|
|||||||
...props
|
...props
|
||||||
}: CarouselLayoutProps) {
|
}: CarouselLayoutProps) {
|
||||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [prevTiles, setPrevTiles] = React.useState(0)
|
||||||
|
const { width, height } = useSize(asideEl)
|
||||||
|
const carouselOrientation = orientation
|
||||||
|
? orientation
|
||||||
|
: height >= width
|
||||||
|
? 'vertical'
|
||||||
|
: 'horizontal'
|
||||||
|
|
||||||
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
const tileSpan =
|
||||||
orientation: orientation ?? 'vertical',
|
carouselOrientation === 'vertical'
|
||||||
maxVisibleTiles: MIN_VISIBLE_TILES,
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
})
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||||
|
const scrollBarWidth = getScrollBarWidth()
|
||||||
|
|
||||||
// Stable callback + identity check: the parent only re-renders when the
|
const tilesThatFit =
|
||||||
// derived layout genuinely changed, not on every resize tick.
|
carouselOrientation === 'vertical'
|
||||||
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
setLayout((prev) =>
|
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
prev.orientation === next.orientation &&
|
|
||||||
prev.maxVisibleTiles === next.maxVisibleTiles
|
|
||||||
? prev
|
|
||||||
: next
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||||
|
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||||
|
maxVisibleTiles = Math.round(prevTiles)
|
||||||
|
} else if (prevTiles !== tilesThatFit) {
|
||||||
|
setPrevTiles(tilesThatFit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (asideEl.current) {
|
||||||
|
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||||
|
asideEl.current.style.setProperty(
|
||||||
|
'--lk-max-visible-tiles',
|
||||||
|
maxVisibleTiles.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [maxVisibleTiles, carouselOrientation])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<aside
|
||||||
<CarouselLayoutObserver
|
key={carouselOrientation}
|
||||||
asideEl={asideEl}
|
className="lk-carousel"
|
||||||
orientation={orientation}
|
ref={asideEl}
|
||||||
onLayoutChange={handleLayoutChange}
|
{...props}
|
||||||
/>
|
>
|
||||||
{/* `key` intentionally remounts the container when orientation flips, */}
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
{/* which resets scroll position and re-runs the observer measurement. */}
|
</aside>
|
||||||
<aside
|
|
||||||
key={layout.orientation}
|
|
||||||
className="lk-carousel"
|
|
||||||
ref={asideEl}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,36 +10,6 @@ import { mergeProps } from '@/utils/mergeProps'
|
|||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from './PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
|
||||||
|
|
||||||
interface GridLayoutObserverProps {
|
|
||||||
gridEl: React.RefObject<HTMLDivElement>
|
|
||||||
trackCount: number
|
|
||||||
onMaxTilesChange: (maxTiles: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Headless component that runs the layout calculation in isolation and
|
|
||||||
* reports the resulting tile capacity upward.
|
|
||||||
*
|
|
||||||
* `useGridLayout` re-renders its host on every layout recalculation
|
|
||||||
* (e.g. container resizes). Rendering it in a null child means only this
|
|
||||||
* component churns; the parent `GridLayout` re-renders solely when
|
|
||||||
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
|
||||||
*/
|
|
||||||
const GridLayoutObserver = ({
|
|
||||||
gridEl,
|
|
||||||
trackCount,
|
|
||||||
onMaxTilesChange,
|
|
||||||
}: GridLayoutObserverProps) => {
|
|
||||||
const { layout } = useGridLayout(gridEl, trackCount)
|
|
||||||
useEffect(() => {
|
|
||||||
onMaxTilesChange(layout.maxTiles)
|
|
||||||
}, [onMaxTilesChange, layout.maxTiles])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
@@ -67,15 +37,14 @@ export interface GridLayoutProps
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
const gridEl = useRef<HTMLDivElement>(null)
|
const gridEl = React.createRef<HTMLDivElement>()
|
||||||
const [maxTiles, setMaxTiles] = useState(1)
|
|
||||||
|
|
||||||
const elementProps = React.useMemo(
|
const elementProps = React.useMemo(
|
||||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||||
[props]
|
[props]
|
||||||
)
|
)
|
||||||
const pagination = usePagination(maxTiles, tracks)
|
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||||
useSpeakerPromotionTrigger(pagination.tracks)
|
const pagination = usePagination(layout.maxTiles, tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
onLeftSwipe: pagination.nextPage,
|
onLeftSwipe: pagination.nextPage,
|
||||||
@@ -88,13 +57,8 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|||||||
data-lk-pagination={pagination.totalPageCount > 1}
|
data-lk-pagination={pagination.totalPageCount > 1}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
>
|
>
|
||||||
<GridLayoutObserver
|
|
||||||
gridEl={gridEl}
|
|
||||||
trackCount={tracks.length}
|
|
||||||
onMaxTilesChange={setMaxTiles}
|
|
||||||
/>
|
|
||||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||||
{tracks.length > maxTiles && (
|
{tracks.length > layout.maxTiles && (
|
||||||
<>
|
<>
|
||||||
<PaginationIndicator
|
<PaginationIndicator
|
||||||
totalPageCount={pagination.totalPageCount}
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
|
|
||||||
|
|
||||||
export const PinAnnouncer = () => {
|
|
||||||
usePinAnnouncements()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { FocusLayoutContainer, useTracks } from '@livekit/components-react'
|
|
||||||
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
|
||||||
import { FocusLayout } from '@/features/layout/components/FocusLayout'
|
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
|
||||||
import { GridLayout } from '@/features/layout/components/GridLayout'
|
|
||||||
import {
|
|
||||||
isEqualTrackRef,
|
|
||||||
isTrackReference,
|
|
||||||
log,
|
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
} from '@livekit/components-core'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
export const StageLayout = () => {
|
|
||||||
const lastAutoFocusedScreenShareTrack =
|
|
||||||
useRef<TrackReferenceOrPlaceholder | null>(null)
|
|
||||||
|
|
||||||
const tracks = useTracks(
|
|
||||||
[
|
|
||||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
|
||||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
|
||||||
],
|
|
||||||
{ updateOnlyOn: [], onlySubscribed: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
const screenShareTracks = tracks
|
|
||||||
.filter(isTrackReference)
|
|
||||||
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
|
|
||||||
const carouselTracks = tracks.filter(
|
|
||||||
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
|
||||||
)
|
|
||||||
|
|
||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
|
||||||
useEffect(() => {
|
|
||||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
|
||||||
if (
|
|
||||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
|
||||||
lastAutoFocusedScreenShareTrack.current === null
|
|
||||||
) {
|
|
||||||
log.debug('Auto set screen share focus:', {
|
|
||||||
newScreenShareTrack: screenShareTracks[0],
|
|
||||||
})
|
|
||||||
setPinnedTrack(screenShareTracks[0])
|
|
||||||
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
|
||||||
} else if (
|
|
||||||
lastAutoFocusedScreenShareTrack.current &&
|
|
||||||
!screenShareTracks.some(
|
|
||||||
(track) =>
|
|
||||||
track.publication.trackSid ===
|
|
||||||
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
log.debug('Auto clearing screen share focus.')
|
|
||||||
clearPinnedTrack()
|
|
||||||
lastAutoFocusedScreenShareTrack.current = null
|
|
||||||
}
|
|
||||||
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
|
|
||||||
const updatedFocusTrack = tracks.find(
|
|
||||||
(tr) =>
|
|
||||||
tr.participant.identity === pinnedTrackRef.participant.identity &&
|
|
||||||
tr.source === pinnedTrackRef.source
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
updatedFocusTrack !== pinnedTrackRef &&
|
|
||||||
isTrackReference(updatedFocusTrack)
|
|
||||||
) {
|
|
||||||
setPinnedTrack(updatedFocusTrack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
screenShareTracks
|
|
||||||
.map(
|
|
||||||
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
|
|
||||||
)
|
|
||||||
.join(),
|
|
||||||
pinnedTrackRef?.publication?.trackSid,
|
|
||||||
tracks,
|
|
||||||
])
|
|
||||||
/* eslint-enable react-hooks/exhaustive-deps */
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!pinnedTrackRef ? (
|
|
||||||
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
|
||||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
|
||||||
<ParticipantTile />
|
|
||||||
</GridLayout>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="lk-focus-layout-wrapper" style={{ height: 'auto' }}>
|
|
||||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
|
||||||
<CarouselLayout
|
|
||||||
tracks={carouselTracks}
|
|
||||||
style={{
|
|
||||||
minWidth: '200px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ParticipantTile />
|
|
||||||
</CarouselLayout>
|
|
||||||
<FocusLayout trackRef={pinnedTrackRef} />
|
|
||||||
</FocusLayoutContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
|
|
||||||
export const usePinAnnouncements = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
|
||||||
const { t: tRooms } = useTranslation('rooms')
|
|
||||||
|
|
||||||
const room = useRoomContext()
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const lastPinnedIdentityRef = useRef<string | null>(null)
|
|
||||||
|
|
||||||
const getAnnouncementName = useCallback(
|
|
||||||
(participant?: Participant | null) => {
|
|
||||||
if (!participant) return tRooms('participants.unknown')
|
|
||||||
return participant.isLocal
|
|
||||||
? tRooms('participants.you')
|
|
||||||
: getParticipantName(participant)
|
|
||||||
},
|
|
||||||
[tRooms]
|
|
||||||
)
|
|
||||||
|
|
||||||
const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 1. unpin
|
|
||||||
if (!pinnedIdentity) {
|
|
||||||
const lastIdentity = lastPinnedIdentityRef.current
|
|
||||||
if (!lastIdentity) return
|
|
||||||
|
|
||||||
const lastParticipant =
|
|
||||||
room.localParticipant.identity === lastIdentity
|
|
||||||
? room.localParticipant
|
|
||||||
: room.remoteParticipants.get(lastIdentity)
|
|
||||||
|
|
||||||
announce(
|
|
||||||
lastParticipant?.isLocal
|
|
||||||
? t('self.unpin')
|
|
||||||
: t('unpin', { name: getAnnouncementName(lastParticipant) })
|
|
||||||
)
|
|
||||||
|
|
||||||
lastPinnedIdentityRef.current = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. same pin → do nothing
|
|
||||||
if (lastPinnedIdentityRef.current === pinnedIdentity) return
|
|
||||||
|
|
||||||
// 3. new pin
|
|
||||||
const participant =
|
|
||||||
room.localParticipant.identity === pinnedIdentity
|
|
||||||
? room.localParticipant
|
|
||||||
: room.remoteParticipants.get(pinnedIdentity)
|
|
||||||
|
|
||||||
lastPinnedIdentityRef.current = pinnedIdentity
|
|
||||||
|
|
||||||
announce(
|
|
||||||
participant?.isLocal
|
|
||||||
? t('self.pin')
|
|
||||||
: t('pin', { name: getAnnouncementName(participant) })
|
|
||||||
)
|
|
||||||
}, [pinnedIdentity, announce, getAnnouncementName, room, t])
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { RoomEvent } from 'livekit-client'
|
|
||||||
import type { Participant } from 'livekit-client'
|
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
|
||||||
import { useEffect, useReducer, useRef } from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tripwire for speaker promotion.
|
|
||||||
*
|
|
||||||
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
|
||||||
* re-render of the host component only when an active speaker has none of
|
|
||||||
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
|
||||||
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
|
||||||
* state and performs the actual swap.
|
|
||||||
*
|
|
||||||
* Speakers already visible are ignored. Everything else costs zero React work.
|
|
||||||
*
|
|
||||||
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
|
||||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
|
||||||
*/
|
|
||||||
export function useSpeakerPromotionTrigger(
|
|
||||||
sortedTiles: TrackReferenceOrPlaceholder[]
|
|
||||||
) {
|
|
||||||
const room = useRoomContext()
|
|
||||||
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
|
||||||
|
|
||||||
// Refs so the listener reads current values without re-subscribing
|
|
||||||
// and without itself being a render dependency.
|
|
||||||
const tilesRef = useRef(sortedTiles)
|
|
||||||
tilesRef.current = sortedTiles
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
|
||||||
const tiles = tilesRef.current
|
|
||||||
const hiddenSpeakerExists = speakers.some(
|
|
||||||
(speaker) =>
|
|
||||||
!tiles.some((t) => t.participant.identity === speaker.identity)
|
|
||||||
)
|
|
||||||
if (hiddenSpeakerExists) {
|
|
||||||
forceRender()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
|
||||||
return () => {
|
|
||||||
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
|
||||||
}
|
|
||||||
}, [room])
|
|
||||||
}
|
|
||||||
@@ -168,31 +168,6 @@ export const MainNotificationToast = () => {
|
|||||||
}
|
}
|
||||||
}, [room, triggerNotificationSoundIfRoomIsSmall])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const removeParticipantNotifications = (participant: Participant) => {
|
const removeParticipantNotifications = (participant: Participant) => {
|
||||||
toastQueue.visibleToasts.forEach((toast) => {
|
toastQueue.visibleToasts.forEach((toast) => {
|
||||||
|
|||||||
@@ -14,5 +14,4 @@ export const NotificationDuration = {
|
|||||||
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
||||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||||
ROLE_CHANGED: ToastDuration.LONG,
|
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -17,5 +17,4 @@ export enum NotificationType {
|
|||||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||||
RecordingSaving = 'recordingSaving',
|
RecordingSaving = 'recordingSaving',
|
||||||
PermissionsRemoved = 'permissionsRemoved',
|
PermissionsRemoved = 'permissionsRemoved',
|
||||||
RoleChanged = 'roleChanged',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { Button as RACButton } from 'react-aria-components'
|
|||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
|
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||||
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack, styled } from '@/styled-system/jsx'
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
import { Div } from '@/primitives'
|
import { Div } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
import { setPinnedTrack } from '@/stores/layout'
|
|
||||||
|
|
||||||
const ClickableToast = styled(RACButton, {
|
const ClickableToast = styled(RACButton, {
|
||||||
base: {
|
base: {
|
||||||
@@ -28,6 +28,7 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
state,
|
state,
|
||||||
ref
|
ref
|
||||||
)
|
)
|
||||||
|
const layoutContext = useMaybeLayoutContext()
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
|
|
||||||
if (!participant) return
|
if (!participant) return
|
||||||
@@ -37,13 +38,18 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
publication: participant.getTrackPublication(Source.Camera),
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
source: Source.Camera,
|
source: Source.Camera,
|
||||||
}
|
}
|
||||||
|
const pinParticipant = () => {
|
||||||
|
layoutContext?.pin.dispatch?.({
|
||||||
|
msg: 'set_pin',
|
||||||
|
trackReference,
|
||||||
|
})
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
<ClickableToast
|
<ClickableToast
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
setPinnedTrack(trackReference)
|
pinParticipant()
|
||||||
closeButtonProps.onPress?.(e)
|
closeButtonProps.onPress?.(e)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { ToastRecordingSaving } from './ToastRecordingSaving'
|
|||||||
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||||
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
|
||||||
|
|
||||||
interface ToastRegionProps extends AriaToastRegionProps {
|
interface ToastRegionProps extends AriaToastRegionProps {
|
||||||
state: ToastState<ToastData>
|
state: ToastState<ToastData>
|
||||||
@@ -71,9 +70,6 @@ const renderToast = (
|
|||||||
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
|
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
|
||||||
)
|
)
|
||||||
|
|
||||||
case NotificationType.RoleChanged:
|
|
||||||
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <Toast key={toast.key} toast={toast} state={state} />
|
return <Toast key={toast.key} toast={toast} state={state} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+2
-2
@@ -7,8 +7,8 @@ import { RiInfinityLine } from '@remixicon/react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { usePrevious } from '@/hooks/usePrevious'
|
import { usePrevious } from '@/hooks/usePrevious'
|
||||||
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
|
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||||
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
|
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||||
import { NotificationType } from '@/features/notifications'
|
import { NotificationType } from '@/features/notifications'
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import {
|
|
||||||
ConnectionQualityIndicator,
|
|
||||||
LockLockedIcon,
|
|
||||||
ScreenShareIcon,
|
|
||||||
useIsEncrypted,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
|
||||||
import { ParticipantName } from './ParticipantName'
|
|
||||||
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
|
||||||
|
|
||||||
export const ParticipantMetadata = ({
|
|
||||||
displayedName,
|
|
||||||
participant,
|
|
||||||
isScreenShare,
|
|
||||||
}: {
|
|
||||||
displayedName: string
|
|
||||||
participant: Participant
|
|
||||||
isScreenShare: boolean
|
|
||||||
}) => {
|
|
||||||
const isEncrypted = useIsEncrypted(participant)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lk-participant-metadata">
|
|
||||||
<HStack gap={0.25}>
|
|
||||||
{!isScreenShare && <MutedMicIndicator participant={participant} />}
|
|
||||||
<RaisedHandMetadataWrapper
|
|
||||||
participant={participant}
|
|
||||||
enabled={!isScreenShare}
|
|
||||||
>
|
|
||||||
{isScreenShare && (
|
|
||||||
<ScreenShareIcon
|
|
||||||
style={{
|
|
||||||
maxWidth: '20px',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isEncrypted && !isScreenShare && (
|
|
||||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
|
||||||
)}
|
|
||||||
<div className="lk-participant-name-wrapper">
|
|
||||||
<ParticipantName
|
|
||||||
displayedName={displayedName}
|
|
||||||
isScreenShare={isScreenShare}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</RaisedHandMetadataWrapper>
|
|
||||||
</HStack>
|
|
||||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import React, { CSSProperties } from 'react'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const participantNameStyles: CSSProperties = {
|
|
||||||
paddingBottom: '0.1rem',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}
|
|
||||||
|
|
||||||
const participantNameScreenShareStyles: CSSProperties = {
|
|
||||||
...participantNameStyles,
|
|
||||||
marginLeft: '0.4rem',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantName = React.memo(
|
|
||||||
({
|
|
||||||
displayedName,
|
|
||||||
isScreenShare = false,
|
|
||||||
}: {
|
|
||||||
displayedName?: string
|
|
||||||
isScreenShare: boolean
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
|
||||||
if (isScreenShare) {
|
|
||||||
return (
|
|
||||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
|
||||||
{t('screenShare', { name: displayedName })}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
|
||||||
{displayedName}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ParticipantName.displayName = 'ParticipantName'
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { Avatar } from '@/components/Avatar'
|
|
||||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
|
||||||
import React, { useMemo } from 'react'
|
|
||||||
|
|
||||||
const StyledParticipantPlaceHolder = styled('div', {
|
|
||||||
base: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
containerType: 'size',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const StyledAvatarWrapper = styled('div', {
|
|
||||||
base: {
|
|
||||||
borderRadius: '50%',
|
|
||||||
aspectRatio: '1 / 1',
|
|
||||||
width: 'min(90cqmin, 160px)',
|
|
||||||
fontSize: 'min(27cqmin, 48px)',
|
|
||||||
'[data-lk-speaking="true"] &': {
|
|
||||||
animation: 'pulse 1s infinite',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ParticipantPlaceholderProps = {
|
|
||||||
color: string
|
|
||||||
displayedNamed: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantPlaceholder = React.memo(
|
|
||||||
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
|
|
||||||
const backgroundGradient = useMemo(
|
|
||||||
() => getParticipantBackgroundGradient(color),
|
|
||||||
[color]
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<StyledParticipantPlaceHolder
|
|
||||||
style={{
|
|
||||||
backgroundColor: color,
|
|
||||||
backgroundImage: backgroundGradient,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<StyledAvatarWrapper>
|
|
||||||
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
|
|
||||||
</StyledAvatarWrapper>
|
|
||||||
</StyledParticipantPlaceHolder>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import React, { ReactNode, RefObject, useEffect, useRef } from 'react'
|
|
||||||
import {
|
|
||||||
useRaisedHand,
|
|
||||||
useRaisedHandPosition,
|
|
||||||
} from '@/features/rooms/livekit/hooks/useRaisedHand'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { RiHand } from '@remixicon/react'
|
|
||||||
|
|
||||||
const PositionInQueue = React.memo(
|
|
||||||
({ positionInQueue }: { positionInQueue?: number }) => {
|
|
||||||
if (!positionInQueue) return
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.1rem' }}
|
|
||||||
>
|
|
||||||
<span>{positionInQueue}</span>
|
|
||||||
<RiHand
|
|
||||||
color="black"
|
|
||||||
size={16}
|
|
||||||
style={{
|
|
||||||
marginRight: '0.4rem',
|
|
||||||
marginLeft: '0.1rem',
|
|
||||||
minWidth: '16px',
|
|
||||||
animationDuration: '300ms',
|
|
||||||
animationName: 'wave_hand',
|
|
||||||
animationIterationCount: '2',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
PositionInQueue.displayName = 'PositionInQueue'
|
|
||||||
|
|
||||||
const RaisedHandActiveItem = ({
|
|
||||||
participant,
|
|
||||||
targetRef,
|
|
||||||
}: {
|
|
||||||
participant: Participant
|
|
||||||
targetRef: RefObject<HTMLElement | null>
|
|
||||||
}) => {
|
|
||||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
|
||||||
participant,
|
|
||||||
})
|
|
||||||
useEffect(() => {
|
|
||||||
const el = targetRef.current
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
el.style.backgroundColor = firstInQueue ? '#fde047' : 'white'
|
|
||||||
el.style.color = 'black'
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
el.style.backgroundColor = ''
|
|
||||||
el.style.color = ''
|
|
||||||
}
|
|
||||||
}, [targetRef, firstInQueue])
|
|
||||||
|
|
||||||
return <PositionInQueue positionInQueue={positionInQueue} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RaisedHandMetadataWrapper = ({
|
|
||||||
participant,
|
|
||||||
enabled = true,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
participant: Participant
|
|
||||||
enabled?: boolean
|
|
||||||
children: ReactNode
|
|
||||||
}) => {
|
|
||||||
const ref = useRef(null)
|
|
||||||
|
|
||||||
const { isHandRaised } = useRaisedHand({ participant })
|
|
||||||
const showHand = isHandRaised && enabled
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="lk-participant-metadata-item"
|
|
||||||
style={{
|
|
||||||
padding: '0.1rem 0.25rem',
|
|
||||||
transition: 'background 200ms ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showHand && (
|
|
||||||
<RaisedHandActiveItem participant={participant} targetRef={ref} />
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const EffectsButton = React.memo(() => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size={'sm'}
|
|
||||||
variant={'primaryTextDark'}
|
|
||||||
square
|
|
||||||
tooltip={t('effects')}
|
|
||||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
|
||||||
>
|
|
||||||
<RiImageCircleAiFill />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
EffectsButton.displayName = 'EffectsButton'
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import {
|
|
||||||
isEqualTrackRef,
|
|
||||||
TrackReferenceOrPlaceholder,
|
|
||||||
} from '@livekit/components-core'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const FocusButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
|
||||||
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
|
||||||
>
|
|
||||||
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
|
||||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
|
||||||
|
|
||||||
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
|
|
||||||
const { isMuted } = useTrackMutedIndicator({
|
|
||||||
participant: participant,
|
|
||||||
source: Track.Source.Microphone,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { muteParticipant } = useMuteParticipant()
|
|
||||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
|
||||||
|
|
||||||
const name = participant.name || participant.identity
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
isDisabled={isMuted}
|
|
||||||
size={'sm'}
|
|
||||||
variant={'primaryTextDark'}
|
|
||||||
square
|
|
||||||
onPress={() => setIsAlertOpen(true)}
|
|
||||||
tooltip={t('muteParticipant', { name })}
|
|
||||||
>
|
|
||||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
|
||||||
</Button>
|
|
||||||
<MuteAlertDialog
|
|
||||||
isOpen={isAlertOpen}
|
|
||||||
onSubmit={() =>
|
|
||||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
|
||||||
}
|
|
||||||
onClose={() => setIsAlertOpen(false)}
|
|
||||||
name={name}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-112
@@ -1,112 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
|
||||||
import { FocusButton } from './FocusButton'
|
|
||||||
import { EffectsButton } from './EffectsButton'
|
|
||||||
import { MuteButton } from './MuteButton'
|
|
||||||
import { ZoomButton } from './ZoomButton'
|
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
|
||||||
|
|
||||||
type FadeOverlayProps = {
|
|
||||||
children: ReactNode
|
|
||||||
hasKeyboardFocus: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
|
||||||
const [active, setActive] = useState(false)
|
|
||||||
const idleTimerRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
const clearIdleTimer = () => {
|
|
||||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const armIdleTimer = () => {
|
|
||||||
clearIdleTimer()
|
|
||||||
idleTimerRef.current = window.setTimeout(() => {
|
|
||||||
setActive(false)
|
|
||||||
}, MOUSE_IDLE_TIME)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleActivity = () => {
|
|
||||||
setActive(true)
|
|
||||||
armIdleTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => clearIdleTimer, [])
|
|
||||||
|
|
||||||
const isVisible = hasKeyboardFocus || active
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
left: '0',
|
|
||||||
top: '0',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
})}
|
|
||||||
data-visible={isVisible || undefined}
|
|
||||||
aria-hidden={!isVisible}
|
|
||||||
onMouseEnter={handleActivity}
|
|
||||||
onMouseMove={handleActivity}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
clearIdleTimer()
|
|
||||||
setActive(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isVisible && children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
|
||||||
trackRef,
|
|
||||||
hasKeyboardFocus,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
hasKeyboardFocus: boolean
|
|
||||||
}) => {
|
|
||||||
const participant = trackRef.participant
|
|
||||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
|
||||||
const isLocal = participant.isLocal
|
|
||||||
const canMute = useCanMute(participant)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
backgroundColor: 'primaryDark.50',
|
|
||||||
zIndex: 1,
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
display: 'flex',
|
|
||||||
opacity: 0.6,
|
|
||||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
|
||||||
_hover: {
|
|
||||||
opacity: 0.95,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<HStack gap={0.5} padding={0.5}>
|
|
||||||
<FocusButton trackRef={trackRef} />
|
|
||||||
{!isScreenShare ? (
|
|
||||||
<>
|
|
||||||
{isLocal ? (
|
|
||||||
<EffectsButton />
|
|
||||||
) : (
|
|
||||||
canMute && <MuteButton participant={participant} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
</FadeOverlay>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import { RiFullscreenLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
export const ZoomButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
|
||||||
trackRef,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isFullscreenAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('fullScreen')}
|
|
||||||
onPress={() => toggleFullScreen()}
|
|
||||||
>
|
|
||||||
<RiFullscreenLine />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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'
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import { ReactNode, useId, useState } from 'react'
|
|
||||||
import { RiArrowUpSLine } from '@remixicon/react'
|
|
||||||
import { styled, HStack, VStack } from '@/styled-system/jsx'
|
|
||||||
|
|
||||||
const Container = styled('div', {
|
|
||||||
base: {
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'greyscale.250',
|
|
||||||
borderRadius: '8px',
|
|
||||||
margin: '0 .625rem 0.9375rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Header = styled('button', {
|
|
||||||
base: {
|
|
||||||
minHeight: '2.5rem',
|
|
||||||
paddingX: '1.25rem',
|
|
||||||
paddingY: '0.5rem',
|
|
||||||
gap: '0.5rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
fontSize: '1rem',
|
|
||||||
transition: 'background 200ms',
|
|
||||||
borderTopRadius: '7px', // container radius (8) minus its 1px border
|
|
||||||
_hover: { backgroundColor: 'greyscale.50' },
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
isOpen: { false: { borderRadius: '7px' } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Chevron = styled(RiArrowUpSLine, {
|
|
||||||
base: { transition: 'transform 200ms', flexShrink: 0 },
|
|
||||||
variants: {
|
|
||||||
isOpen: { false: { transform: 'rotate(180deg)' } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const List = styled(VStack, {
|
|
||||||
base: {
|
|
||||||
borderTop: '1px solid',
|
|
||||||
borderTopColor: 'greyscale.250',
|
|
||||||
alignItems: 'start',
|
|
||||||
minHeight: 0,
|
|
||||||
flexGrow: 1,
|
|
||||||
paddingY: '0.5rem',
|
|
||||||
paddingX: '1rem',
|
|
||||||
gap: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ParticipantsCollapsibleSectionProps = {
|
|
||||||
heading: string
|
|
||||||
count: number
|
|
||||||
action?: ReactNode
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantsCollapsibleSection = ({
|
|
||||||
heading,
|
|
||||||
count,
|
|
||||||
action,
|
|
||||||
children,
|
|
||||||
}: ParticipantsCollapsibleSectionProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(true)
|
|
||||||
const listId = useId()
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Header
|
|
||||||
type="button"
|
|
||||||
isOpen={isOpen}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
aria-controls={listId}
|
|
||||||
onClick={() => setIsOpen((open) => !open)}
|
|
||||||
>
|
|
||||||
<HStack justify="space-between" width="100%">
|
|
||||||
<span>{heading}</span>
|
|
||||||
<span>{count}</span>
|
|
||||||
</HStack>
|
|
||||||
<Chevron size={32} isOpen={isOpen} aria-hidden />
|
|
||||||
</Header>
|
|
||||||
{isOpen && (
|
|
||||||
<List id={listId} role="list">
|
|
||||||
{action}
|
|
||||||
{children}
|
|
||||||
</List>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useRemoteParticipants } from '@livekit/components-react'
|
|
||||||
import { srOnly } from '@/styles/a11y'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { RiInfinityLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
const badgeStyles = css({
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-.25rem',
|
|
||||||
right: '-.25rem',
|
|
||||||
width: '1.25rem',
|
|
||||||
height: '1.25rem',
|
|
||||||
backgroundColor: 'gray',
|
|
||||||
borderRadius: '50%',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
zIndex: 1,
|
|
||||||
userSelect: 'none',
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Isolated so participant join/leave events only re-render the badge,
|
|
||||||
* not the button, tooltip or shortcut registration.
|
|
||||||
*/
|
|
||||||
export const ParticipantsCount = React.memo(
|
|
||||||
({ describedById }: { describedById: string }) => {
|
|
||||||
const { t } = useTranslation('rooms', {
|
|
||||||
keyPrefix: 'controls.participants',
|
|
||||||
})
|
|
||||||
const remoteParticipants = useRemoteParticipants({
|
|
||||||
updateOnlyOn: [],
|
|
||||||
})
|
|
||||||
const count = (remoteParticipants?.length ?? 0) + 1
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span id={describedById} className={srOnly}>
|
|
||||||
{t('count', { count })}
|
|
||||||
</span>
|
|
||||||
<div className={badgeStyles} aria-hidden>
|
|
||||||
{count < 100 ? count : <RiInfinityLine size={10} />}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ParticipantsCount.displayName = 'ParticipantsCount'
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import {
|
|
||||||
useRemoteParticipants,
|
|
||||||
useRoomContext,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { RoomEvent } from 'livekit-client'
|
|
||||||
import { Div, H } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ParticipantRow } from './ParticipantRow'
|
|
||||||
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
|
||||||
import { LowerAllHandsButton } from './LowerAllHandsButton'
|
|
||||||
import { MuteEveryoneButton } from './MuteEveryoneButton'
|
|
||||||
import { WaitingParticipantsSection } from './WaitingParticipantsSection'
|
|
||||||
import { RaisedHandRow } from './RaisedHandRow'
|
|
||||||
|
|
||||||
const JoinedParticipantsSections = () => {
|
|
||||||
const room = useRoomContext()
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
|
||||||
|
|
||||||
// `updateOnlyOn` filters on the room event *type*, not on which participant
|
|
||||||
// the event is about. `ParticipantNameChanged` and `ParticipantAttributesChanged`
|
|
||||||
// are room-wide and fire for the local participant too, so this hook re-emits
|
|
||||||
// (and we re-render) on local changes, despite its name.
|
|
||||||
//
|
|
||||||
// That is deliberate here: we prepend `room.localParticipant` to the list below,
|
|
||||||
// and its hand-raise state comes from `attributes.handRaisedAt`. Without the
|
|
||||||
// local events waking this component up, raising your own hand would not
|
|
||||||
// update the UI. Do not "optimise" these away.
|
|
||||||
const remoteParticipants = useRemoteParticipants({
|
|
||||||
updateOnlyOn: [
|
|
||||||
RoomEvent.ParticipantNameChanged,
|
|
||||||
RoomEvent.ParticipantAttributesChanged,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const allParticipants = [
|
|
||||||
room.localParticipant,
|
|
||||||
...[...remoteParticipants].sort((a, b) =>
|
|
||||||
(a.name || a.identity).localeCompare(b.name || b.identity)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
const participantsWithRaisedHands = allParticipants
|
|
||||||
.filter((p) => !!p.attributes.handRaisedAt)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const raisedAtA = Date.parse(a.attributes.handRaisedAt!)
|
|
||||||
const raisedAtB = Date.parse(b.attributes.handRaisedAt!)
|
|
||||||
return (
|
|
||||||
(Number.isNaN(raisedAtA) ? Infinity : raisedAtA) -
|
|
||||||
(Number.isNaN(raisedAtB) ? Infinity : raisedAtB)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{participantsWithRaisedHands.length > 0 && (
|
|
||||||
<ParticipantsCollapsibleSection
|
|
||||||
heading={t('raisedHands')}
|
|
||||||
count={participantsWithRaisedHands.length}
|
|
||||||
action={
|
|
||||||
<LowerAllHandsButton participants={participantsWithRaisedHands} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{participantsWithRaisedHands.map((p) => (
|
|
||||||
<RaisedHandRow key={p.identity} participant={p} />
|
|
||||||
))}
|
|
||||||
</ParticipantsCollapsibleSection>
|
|
||||||
)}
|
|
||||||
<ParticipantsCollapsibleSection
|
|
||||||
heading={t('contributors')}
|
|
||||||
count={allParticipants.length}
|
|
||||||
action={<MuteEveryoneButton participants={remoteParticipants} />}
|
|
||||||
>
|
|
||||||
{allParticipants.map((p) => (
|
|
||||||
<ParticipantRow key={p.identity} participant={p} />
|
|
||||||
))}
|
|
||||||
</ParticipantsCollapsibleSection>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantsList = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Div overflowY="scroll">
|
|
||||||
<H
|
|
||||||
lvl={2}
|
|
||||||
className={css({
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: 'greyscale.600',
|
|
||||||
padding: '0 1.5rem',
|
|
||||||
marginBottom: '0.83em',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('subheading').toUpperCase()}
|
|
||||||
</H>
|
|
||||||
<WaitingParticipantsSection />
|
|
||||||
<JoinedParticipantsSections />
|
|
||||||
</Div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
|
||||||
import { RiPushpin2Fill } from '@remixicon/react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
import { isEqualTrackRef } from '@livekit/components-core'
|
|
||||||
import Source = Track.Source
|
|
||||||
|
|
||||||
export const PinBadge = ({ participant }: { participant: Participant }) => {
|
|
||||||
const cameraTrackRef = useMemo(() => {
|
|
||||||
return {
|
|
||||||
participant: participant,
|
|
||||||
publication: participant.getTrackPublication(Source.Camera),
|
|
||||||
source: Source.Camera,
|
|
||||||
}
|
|
||||||
}, [participant])
|
|
||||||
|
|
||||||
const screenShareTrackRef = useMemo(() => {
|
|
||||||
return {
|
|
||||||
participant: participant,
|
|
||||||
publication: participant.getTrackPublication(Source.ScreenShare),
|
|
||||||
source: Source.ScreenShare,
|
|
||||||
}
|
|
||||||
}, [participant])
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const inFocus =
|
|
||||||
isEqualTrackRef(pinnedTrackRef, cameraTrackRef) ||
|
|
||||||
isEqualTrackRef(pinnedTrackRef, screenShareTrackRef)
|
|
||||||
|
|
||||||
if (!inFocus) return
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
height: '18px',
|
|
||||||
width: '18px',
|
|
||||||
borderRadius: '100%',
|
|
||||||
background: 'white',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '-2px',
|
|
||||||
right: '-4px',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<RiPushpin2Fill size={14} aria-hidden />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Avatar } from '@/components/Avatar'
|
|
||||||
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
|
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
|
||||||
import type { Participant } from 'livekit-client'
|
|
||||||
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,
|
|
||||||
name,
|
|
||||||
}: {
|
|
||||||
participant: Participant
|
|
||||||
name: string
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms')
|
|
||||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
square
|
|
||||||
variant="greyscale"
|
|
||||||
size="sm"
|
|
||||||
onPress={() => lowerHandParticipant(participant)}
|
|
||||||
aria-label={t('participants.lowerParticipantHand', { name })}
|
|
||||||
tooltip={t('participants.lowerParticipantHand', { name })}
|
|
||||||
data-attr="participants-lower-hand"
|
|
||||||
>
|
|
||||||
<RiHand />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type HandRaisedListItemProps = {
|
|
||||||
participant: Participant
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
|
||||||
const name = participant.name || participant.identity
|
|
||||||
return (
|
|
||||||
<HStack
|
|
||||||
role="listitem"
|
|
||||||
justify="space-between"
|
|
||||||
key={participant.identity}
|
|
||||||
id={participant.identity}
|
|
||||||
className={css({
|
|
||||||
padding: '0.25rem 0',
|
|
||||||
width: 'full',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<HStack flex="1" minW="0" overflow="hidden">
|
|
||||||
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
|
||||||
<ParticipantName displayedName={name} isLocal={isLocal(participant)} />
|
|
||||||
</HStack>
|
|
||||||
<HStack flexShrink={0}>
|
|
||||||
<AdminOrOwnerOnly>
|
|
||||||
<ActionButton participant={participant} name={name} />
|
|
||||||
</AdminOrOwnerOnly>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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,28 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
|
||||||
import { WaitingParticipantRow } from './WaitingParticipantRow'
|
|
||||||
import { useWaitingParticipants } from '../hooks/useWaitingParticipants'
|
|
||||||
|
|
||||||
export const WaitingParticipantsSection = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
|
|
||||||
|
|
||||||
const { waitingParticipants, handleParticipantEntry } =
|
|
||||||
useWaitingParticipants()
|
|
||||||
|
|
||||||
if (waitingParticipants?.length == 0) return
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParticipantsCollapsibleSection
|
|
||||||
heading={t('title')}
|
|
||||||
count={waitingParticipants.length}
|
|
||||||
>
|
|
||||||
{waitingParticipants.map((p) => (
|
|
||||||
<WaitingParticipantRow
|
|
||||||
key={p.id}
|
|
||||||
participant={p}
|
|
||||||
onAction={handleParticipantEntry}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ParticipantsCollapsibleSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Menu as RACMenu } from 'react-aria-components'
|
|
||||||
import type { Participant } from 'livekit-client'
|
|
||||||
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 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: '100px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PinMenuItem participant={participant} />
|
|
||||||
{canManage && (
|
|
||||||
<RemoveMenuItem
|
|
||||||
identity={participant.identity}
|
|
||||||
displayedName={participant.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canManage && isAuthenticated && (
|
|
||||||
<PromoteMenuItem
|
|
||||||
identity={participant.identity}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
displayedName={participant.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</RACMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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,35 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
|
||||||
import { MenuItem } from 'react-aria-components'
|
|
||||||
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
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'
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
type PipFocusLayoutProps = {
|
type PipFocusLayoutProps = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo, useMemo, useRef } from 'react'
|
import { memo, useMemo, useRef } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||||
import { computePipGridLayout } from '../../utils/pipGrid'
|
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
type PipScreenShareLayoutProps = {
|
type PipScreenShareLayoutProps = {
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { useTrackToggle } from '@livekit/components-react'
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
|
|
||||||
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { t } = useTranslation('rooms', {
|
const { t } = useTranslation('rooms', {
|
||||||
keyPrefix: 'pictureInPicture',
|
keyPrefix: 'pictureInPicture',
|
||||||
})
|
})
|
||||||
const { enabled: isScreenSharing } = useTrackToggle({
|
const { localParticipant } = useLocalParticipant()
|
||||||
source: Track.Source.ScreenShare,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('stage')}
|
aria-label={t('stage')}
|
||||||
{...(!isScreenSharing ? { inert: '' } : {})}
|
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { VStack } from '@/styled-system/jsx'
|
|||||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
useSettingsDialog,
|
||||||
SettingsDialogExtendedKey,
|
SettingsDialogExtendedKey,
|
||||||
useTranscriptionLanguage,
|
useTranscriptionLanguage,
|
||||||
} from '@/features/settings'
|
} from '@/features/settings'
|
||||||
@@ -34,7 +35,6 @@ import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnab
|
|||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
|
||||||
|
|
||||||
export const TranscriptSidePanel = () => {
|
export const TranscriptSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -48,6 +48,8 @@ export const TranscriptSidePanel = () => {
|
|||||||
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
||||||
useTranscriptionLanguage()
|
useTranscriptionLanguage()
|
||||||
|
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
FeatureFlags.Transcript
|
FeatureFlags.Transcript
|
||||||
|
|||||||
@@ -28,6 +28,3 @@ export type ApiRoom = {
|
|||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
configuration?: RoomConfiguration
|
configuration?: RoomConfiguration
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
|
||||||
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import type { Participant } from 'livekit-client'
|
||||||
import { useRoomData } from '../livekit/hooks/useRoomData'
|
import { useRoomData } from '../livekit/hooks/useRoomData'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
|
||||||
export const useRemoveParticipant = () => {
|
export const useRemoveParticipant = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
|
|
||||||
const removeParticipant = async (identity: string) => {
|
const removeParticipant = async (participant: Participant) => {
|
||||||
if (!data?.id) {
|
if (!data?.id) {
|
||||||
throw new Error('Room id is not available')
|
throw new Error('Room id is not available')
|
||||||
}
|
}
|
||||||
@@ -12,7 +13,7 @@ export const useRemoveParticipant = () => {
|
|||||||
return fetchApi(`rooms/${data.id}/remove-participant/`, {
|
return fetchApi(`rooms/${data.id}/remove-participant/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
participant_identity: identity,
|
participant_identity: participant.identity,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const StyledContainer = styled('div', {
|
const StyledContainer = styled('div', {
|
||||||
@@ -68,36 +67,35 @@ export type ActiveSpeakerProps = {
|
|||||||
pushToTalk?: boolean
|
pushToTalk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActiveSpeaker = React.memo(
|
export const ActiveSpeaker = ({
|
||||||
({ isSpeaking, pushToTalk }: ActiveSpeakerProps) => {
|
isSpeaking,
|
||||||
return (
|
pushToTalk,
|
||||||
<StyledContainer pushToTalk={pushToTalk}>
|
}: ActiveSpeakerProps) => {
|
||||||
<StyledChild
|
return (
|
||||||
pushToTalk={pushToTalk}
|
<StyledContainer pushToTalk={pushToTalk}>
|
||||||
active={isSpeaking}
|
<StyledChild
|
||||||
size="small"
|
pushToTalk={pushToTalk}
|
||||||
style={{
|
active={isSpeaking}
|
||||||
animationDelay: '300ms',
|
size="small"
|
||||||
}}
|
style={{
|
||||||
/>
|
animationDelay: '300ms',
|
||||||
<StyledChild
|
}}
|
||||||
pushToTalk={pushToTalk}
|
/>
|
||||||
active={isSpeaking}
|
<StyledChild
|
||||||
style={{
|
pushToTalk={pushToTalk}
|
||||||
animationDelay: '100ms',
|
active={isSpeaking}
|
||||||
}}
|
style={{
|
||||||
/>
|
animationDelay: '100ms',
|
||||||
<StyledChild
|
}}
|
||||||
pushToTalk={pushToTalk}
|
/>
|
||||||
active={isSpeaking}
|
<StyledChild
|
||||||
size="small"
|
pushToTalk={pushToTalk}
|
||||||
style={{
|
active={isSpeaking}
|
||||||
animationDelay: '500ms',
|
size="small"
|
||||||
}}
|
style={{
|
||||||
/>
|
animationDelay: '500ms',
|
||||||
</StyledContainer>
|
}}
|
||||||
)
|
/>
|
||||||
}
|
</StyledContainer>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
ActiveSpeaker.displayName = 'ActiveSpeaker'
|
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ export const Conference = ({
|
|||||||
prepareConnection()
|
prepareConnection()
|
||||||
}, [room, apiConfig, isConnectionWarmedUp])
|
}, [room, apiConfig, isConnectionWarmedUp])
|
||||||
|
|
||||||
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||||
error: MediaDeviceFailure | null
|
error: MediaDeviceFailure | null
|
||||||
kind: MediaDeviceKind | null
|
kind: MediaDeviceKind | null
|
||||||
@@ -302,7 +303,13 @@ export const Conference = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{!isMobile && <InviteDialog mode={mode} />}
|
{showInviteDialog && !isMobile && (
|
||||||
|
<InviteDialog
|
||||||
|
isOpen={showInviteDialog}
|
||||||
|
onOpenChange={setShowInviteDialog}
|
||||||
|
onClose={() => setShowInviteDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MediaDeviceErrorAlert
|
<MediaDeviceErrorAlert
|
||||||
{...mediaDeviceError}
|
{...mediaDeviceError}
|
||||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
import { Div, Button, P, Bold } from '@/primitives'
|
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
|
||||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
import { Heading, Dialog } from 'react-aria-components'
|
import { Heading, Dialog } from 'react-aria-components'
|
||||||
import { Text, text } from '@/primitives/Text'
|
import { Text, text } from '@/primitives/Text'
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiSpam2Fill,
|
RiSpam2Fill,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
@@ -39,9 +39,7 @@ const StyledRACDialog = styled(Dialog, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
@@ -60,161 +58,163 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
|||||||
copyRoomUrlToClipboard,
|
copyRoomUrlToClipboard,
|
||||||
} = useCopyRoomToClipboard(roomData)
|
} = useCopyRoomToClipboard(roomData)
|
||||||
|
|
||||||
if (!showInviteDialog) return null
|
|
||||||
return (
|
return (
|
||||||
<StyledRACDialog>
|
<StyledRACDialog {...props}>
|
||||||
<VStack
|
{({ close }) => (
|
||||||
alignItems="left"
|
<VStack
|
||||||
justify="start"
|
alignItems="left"
|
||||||
gap={0}
|
justify="start"
|
||||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
gap={0}
|
||||||
>
|
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
>
|
||||||
{t('heading')}
|
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||||
</Heading>
|
{t('heading')}
|
||||||
<Div position="absolute" top="5" right="5">
|
</Heading>
|
||||||
<Button
|
<Div position="absolute" top="5" right="5">
|
||||||
invisible
|
<Button
|
||||||
variant="tertiaryText"
|
invisible
|
||||||
size="xs"
|
variant="tertiaryText"
|
||||||
onPress={() => {
|
size="xs"
|
||||||
setShowInviteDialog(false)
|
onPress={() => {
|
||||||
}}
|
props.onClose?.()
|
||||||
aria-label={t('closeDialog')}
|
close()
|
||||||
>
|
}}
|
||||||
<RiCloseLine />
|
aria-label={t('closeDialog')}
|
||||||
</Button>
|
|
||||||
</Div>
|
|
||||||
<P>{t('description')}</P>
|
|
||||||
{isTelephonyReadyForUse ? (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
marginTop: '0.5rem',
|
|
||||||
gap: '1rem',
|
|
||||||
overflow: 'visible',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Text as="p" wrap="pretty">
|
<RiCloseLine />
|
||||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
</Button>
|
||||||
</Text>
|
</Div>
|
||||||
{isTelephonyReadyForUse && roomUrl && (
|
<P>{t('description')}</P>
|
||||||
<Button
|
{isTelephonyReadyForUse ? (
|
||||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
|
||||||
square
|
|
||||||
size={'sm'}
|
|
||||||
onPress={copyRoomUrlToClipboard}
|
|
||||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
|
||||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
|
||||||
>
|
|
||||||
{isRoomUrlCopied ? (
|
|
||||||
<RiCheckLine aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<RiFileCopyLine aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
width: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
gap: '1rem',
|
||||||
|
overflow: 'visible',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Text as="p" wrap="pretty">
|
<div
|
||||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
className={css({
|
||||||
{telephony?.internationalPhoneNumber}
|
display: 'flex',
|
||||||
</Text>
|
alignItems: 'center',
|
||||||
<Text as="p" wrap="pretty">
|
justifyContent: 'space-between',
|
||||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
})}
|
||||||
{formatPinCode(roomData?.pin_code)}
|
>
|
||||||
</Text>
|
<Text as="p" wrap="pretty">
|
||||||
</div>
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
|
</Text>
|
||||||
|
{isTelephonyReadyForUse && roomUrl && (
|
||||||
|
<Button
|
||||||
|
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||||
|
square
|
||||||
|
size={'sm'}
|
||||||
|
onPress={copyRoomUrlToClipboard}
|
||||||
|
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
|
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
|
>
|
||||||
|
{isRoomUrlCopied ? (
|
||||||
|
<RiCheckLine aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<RiFileCopyLine aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||||
|
{telephony?.internationalPhoneNumber}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||||
|
{formatPinCode(roomData?.pin_code)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={isCopied ? 'success' : 'secondaryText'}
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
onPress={copyRoomToClipboard}
|
||||||
|
data-attr="share-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine
|
||||||
|
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copy')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant={isCopied ? 'success' : 'secondaryText'}
|
variant={isCopied ? 'success' : 'tertiary'}
|
||||||
size="sm"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
style={{
|
|
||||||
justifyContent: 'start',
|
|
||||||
}}
|
|
||||||
onPress={copyRoomToClipboard}
|
onPress={copyRoomToClipboard}
|
||||||
data-attr="share-dialog-copy"
|
data-attr="share-dialog-copy"
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
<>
|
<>
|
||||||
<RiCheckLine
|
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||||
size={18}
|
|
||||||
style={{ marginRight: '8px' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copied')}
|
{t('copied')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RiFileCopyLine
|
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
{t('copyUrl')}
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copy')}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
) : (
|
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||||
<Button
|
<HStack>
|
||||||
variant={isCopied ? 'success' : 'tertiary'}
|
<div
|
||||||
fullWidth
|
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
|
||||||
onPress={copyRoomToClipboard}
|
|
||||||
data-attr="share-dialog-copy"
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
|
||||||
{t('copied')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
|
||||||
{t('copyUrl')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
|
||||||
<HStack>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
backgroundColor: 'primary.200',
|
|
||||||
borderRadius: '50%',
|
|
||||||
padding: '4px',
|
|
||||||
marginTop: '1rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<RiSpam2Fill
|
|
||||||
size={22}
|
|
||||||
className={css({
|
className={css({
|
||||||
fill: 'primary.500',
|
backgroundColor: 'primary.200',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '4px',
|
||||||
|
marginTop: '1rem',
|
||||||
})}
|
})}
|
||||||
/>
|
>
|
||||||
</div>
|
<RiSpam2Fill
|
||||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
size={22}
|
||||||
{t('permissions')}
|
className={css({
|
||||||
</Text>
|
fill: 'primary.500',
|
||||||
</HStack>
|
})}
|
||||||
)}
|
/>
|
||||||
</VStack>
|
</div>
|
||||||
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
|
{t('permissions')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
</StyledRACDialog>
|
</StyledRACDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export const Join = ({
|
|||||||
refetch: refetchRoom,
|
refetch: refetchRoom,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: [keys.room, roomId],
|
queryKey: [keys.room, roomId],
|
||||||
queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
|
queryFn: () => fetchRoom({ roomId, username }),
|
||||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||||
retry: false,
|
retry: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -339,7 +339,7 @@ export const Join = ({
|
|||||||
|
|
||||||
const { status, startWaiting } = useLobby({
|
const { status, startWaiting } = useLobby({
|
||||||
roomId,
|
roomId,
|
||||||
username: username || user?.full_name || 'anonymous',
|
username,
|
||||||
onAccepted: handleAccepted,
|
onAccepted: handleAccepted,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import { useEnterRoom } from '../api/enterRoom'
|
|||||||
import {
|
import {
|
||||||
useListWaitingParticipants,
|
useListWaitingParticipants,
|
||||||
type WaitingParticipant,
|
type WaitingParticipant,
|
||||||
} from '../../participants/api/listWaitingParticipants'
|
} from '../api/listWaitingParticipants'
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
|
||||||
@@ -11,9 +11,6 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useParams } from 'wouter'
|
import { useParams } from 'wouter'
|
||||||
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
|
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
|
||||||
import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { closeSidePanel } from '@/stores/layout'
|
|
||||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
|
||||||
|
|
||||||
export const Admin = () => {
|
export const Admin = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||||
@@ -26,14 +23,6 @@ export const Admin = () => {
|
|||||||
|
|
||||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||||
|
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAdminOrOwner) {
|
|
||||||
closeSidePanel()
|
|
||||||
}
|
|
||||||
}, [isAdminOrOwner])
|
|
||||||
|
|
||||||
const { data: readOnlyData } = useQuery({
|
const { data: readOnlyData } = useQuery({
|
||||||
queryKey: [keys.room, roomId],
|
queryKey: [keys.room, roomId],
|
||||||
queryFn: () => fetchRoom({ roomId }),
|
queryFn: () => fetchRoom({ roomId }),
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
import { ParticipantTile } from './ParticipantTile'
|
||||||
import type { FocusLayoutProps } from '@livekit/components-react'
|
import type { FocusLayoutProps } from '@livekit/components-react'
|
||||||
|
|
||||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||||
+5
-6
@@ -3,10 +3,10 @@ import { Button, Text } from '@/primitives'
|
|||||||
import { useCallback, useMemo, useRef } from 'react'
|
import { useCallback, useMemo, useRef } from 'react'
|
||||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
|
import { useSize } from '../hooks/useResizeObserver'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
|
||||||
import { LocalParticipant } from 'livekit-client'
|
|
||||||
|
|
||||||
export const FullScreenShareWarning = ({
|
export const FullScreenShareWarning = ({
|
||||||
trackReference,
|
trackReference,
|
||||||
@@ -17,6 +17,7 @@ export const FullScreenShareWarning = ({
|
|||||||
|
|
||||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const { width: containerWidth } = useSize(warningContainerRef)
|
const { width: containerWidth } = useSize(warningContainerRef)
|
||||||
|
const { localParticipant } = useLocalParticipant()
|
||||||
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||||
|
|
||||||
const isFullScreenSharing = useMemo(() => {
|
const isFullScreenSharing = useMemo(() => {
|
||||||
@@ -56,10 +57,8 @@ export const FullScreenShareWarning = ({
|
|||||||
screenSharePreferences.enabled && isFullScreenSharing
|
screenSharePreferences.enabled && isFullScreenSharing
|
||||||
|
|
||||||
const handleStopScreenShare = async () => {
|
const handleStopScreenShare = async () => {
|
||||||
const participant = trackReference.participant
|
if (!localParticipant.isScreenShareEnabled) return
|
||||||
if (!(participant instanceof LocalParticipant)) return
|
await localParticipant.setScreenShareEnabled(false, {}, {})
|
||||||
if (!participant.isScreenShareEnabled) return
|
|
||||||
await (participant as LocalParticipant).setScreenShareEnabled(false, {}, {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDismissWarning = useCallback(() => {
|
const handleDismissWarning = useCallback(() => {
|
||||||
@@ -7,9 +7,9 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
|
||||||
|
|
||||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
||||||
@@ -94,6 +94,7 @@ const Description = () => {
|
|||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
return (
|
return (
|
||||||
<P>
|
<P>
|
||||||
{t('settingsPrefix')}{' '}
|
{t('settingsPrefix')}{' '}
|
||||||
|
|||||||
+7
-9
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const Hint = styled('div', {
|
const Hint = styled('div', {
|
||||||
@@ -25,17 +25,15 @@ const Hint = styled('div', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface KeyboardShortcutHintProps {
|
export interface KeyboardShortcutHintProps {
|
||||||
hint?: string
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small reusable bubble used to display and announce keyboard shortcuts,
|
* Small reusable bubble used to display and announce keyboard shortcuts,
|
||||||
* typically when an element receives keyboard focus.
|
* typically when an element receives keyboard focus.
|
||||||
*/
|
*/
|
||||||
export const KeyboardShortcutHint = React.memo(
|
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||||
({ hint }: KeyboardShortcutHintProps) => {
|
children,
|
||||||
return <Hint>{hint}</Hint>
|
}) => {
|
||||||
}
|
return <Hint>{children}</Hint>
|
||||||
)
|
}
|
||||||
|
|
||||||
KeyboardShortcutHint.displayName = 'KeyboardShortcutHint'
|
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
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 './PinMenuItem'
|
||||||
|
import { RemoveMenuItem } from './RemoveMenuItem'
|
||||||
|
|
||||||
|
export const ParticipantMenu = ({
|
||||||
|
participant,
|
||||||
|
}: {
|
||||||
|
participant: Participant
|
||||||
|
}) => {
|
||||||
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
|
const canModerateParticipant = !participant.isLocal && isAdminOrOwner
|
||||||
|
return (
|
||||||
|
<RACMenu
|
||||||
|
style={{
|
||||||
|
minWidth: '75px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PinMenuItem participant={participant} />
|
||||||
|
{canModerateParticipant && <RemoveMenuItem participant={participant} />}
|
||||||
|
</RACMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
@@ -1,6 +1,7 @@
|
|||||||
import { Button, Menu } from '@/primitives'
|
import { Button, Menu } from '@/primitives'
|
||||||
import { RiMore2Fill } from '@remixicon/react'
|
import { RiMore2Fill } from '@remixicon/react'
|
||||||
import { ParticipantMenu } from './ParticipantMenu'
|
import { ParticipantMenu } from './ParticipantMenu'
|
||||||
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ export const ParticipantMenuButton = ({
|
|||||||
participant: Participant
|
participant: Participant
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||||
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
|
if (!isAdminOrOwner) return null
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Button
|
<Button
|
||||||
+4
-17
@@ -1,28 +1,15 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
|
||||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
|
||||||
import { isEqualTrackRef } from '@livekit/components-core'
|
|
||||||
import Source = Track.Source
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
||||||
|
|
||||||
const trackRef = useMemo(() => {
|
const { toggle, inFocus } = useFocusToggleParticipant(participant)
|
||||||
return {
|
|
||||||
participant: participant,
|
|
||||||
publication: participant.getTrackPublication(Source.Camera),
|
|
||||||
source: Source.Camera,
|
|
||||||
}
|
|
||||||
}, [participant])
|
|
||||||
|
|
||||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
|
||||||
const inFocus = isEqualTrackRef(pinnedTrackRef, trackRef)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -30,7 +17,7 @@ export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
|||||||
name: participant.name,
|
name: participant.name,
|
||||||
})}
|
})}
|
||||||
className={menuRecipe({ icon: true }).item}
|
className={menuRecipe({ icon: true }).item}
|
||||||
onAction={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
onAction={toggle}
|
||||||
>
|
>
|
||||||
<HStack gap={0.25}>
|
<HStack gap={0.25}>
|
||||||
{inFocus ? (
|
{inFocus ? (
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user