Compare commits

..

1 Commits

Author SHA1 Message Date
leo 17496d646f 🐛(backend) preserve recording metadata when updating room access
Updating room access rewrote the entire metadata payload, removing information
about active recordings. This caused the frontend to lose track of ongoing
recordings and could trigger 409 errors when attempting to start a new
recording.

Consolidate the duplicated metadata update logic into
`RoomManagement.update_metadata()` and preserve merge behavior instead of
overwriting the full metadata object.
2026-07-22 16:48:42 +02:00
134 changed files with 2088 additions and 3347 deletions
+5 -13
View File
@@ -8,18 +8,6 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item
- ✨(frontend) add connection test feature
## Fixed
- 🐛(transcription) fix silent bug in speaker assignment
- 🐛(summary) extend tasks auto retry logic
- 🐛(summary) properly detect when failure webhook should be sent
### Changed
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
@@ -27,7 +15,11 @@ and this project adheres to
- ⬆️(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
## Fixed
- 🐛(transcription) fix silent bug in speaker assignment
- 🐛(backend) fix bug linked to recording state inconsistency
## [1.24.0] - 2026-07-21
-1
View File
@@ -344,7 +344,6 @@ These are the environmental options available on meet backend.
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| 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_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 |
-42
View File
@@ -1,42 +0,0 @@
"""Connection test API endpoint."""
from datetime import timedelta
from uuid import uuid4
from django.conf import settings
from rest_framework.decorators import api_view, throttle_classes
from rest_framework.response import Response
from core.api.throttling import (
ConnectionTestAnonRateThrottle,
ConnectionTestUserRateThrottle,
)
from core.utils import generate_token
CONNECTION_TEST_USERNAME = "Test connexion"
@api_view(["GET"])
@throttle_classes([ConnectionTestUserRateThrottle, ConnectionTestAnonRateThrottle])
def get_connection_test_config(request):
"""Return a short-lived LiveKit token for an ephemeral connection test room."""
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid4()}"
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
return Response(
{
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room,
"token": generate_token(
room=room,
user=request.user,
username=CONNECTION_TEST_USERNAME,
is_admin_or_owner=False,
ttl=timedelta(seconds=expires_in),
),
"expires_in": expires_in,
},
}
)
-12
View File
@@ -73,15 +73,3 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated users requesting connection test tokens."""
scope = "connection_test"
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous users requesting connection test tokens."""
scope = "connection_test"
@@ -9,6 +9,11 @@ from livekit import api
from core import models, utils
from core.models import Recording
from core.recording.event.notification import notification_service
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
logger = getLogger(__name__)
@@ -39,10 +44,15 @@ class RecordingEventsService:
recording_status = status_mapping.get(egress_status)
if recording_status:
try:
utils.update_room_metadata(
RoomManagement().update_metadata(
room_name, {"recording_status": recording_status}
)
except utils.MetadataUpdateException as e:
except RoomNotFoundException:
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)
@staticmethod
+12 -3
View File
@@ -2,8 +2,12 @@
import logging
from core import utils
from core.models import Recording, RecordingStatusChoices
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .exceptions import (
RecordingStartError,
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
mode = recording.options.get("original_mode", None) or recording.mode
try:
utils.update_room_metadata(
RoomManagement().update_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"}
)
except utils.MetadataUpdateException as e:
except RoomNotFoundException:
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.info(
+14 -23
View File
@@ -11,7 +11,7 @@ from django.conf import settings
from livekit import api
from core import models, utils
from core import models
from core.recording.services.metadata_collector import (
MetadataCollectorException,
MetadataCollectorService,
@@ -23,6 +23,11 @@ from core.recording.services.recording_events import (
)
from .lobby import LobbyService
from .room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
@@ -177,10 +182,15 @@ class LiveKitEventsService:
try:
room_name = str(recording.room.id)
utils.update_room_metadata(
room_name, {}, ["recording_mode", "recording_status"]
RoomManagement().update_metadata(
room_name, remove_keys=["recording_mode", "recording_status"]
)
except utils.MetadataUpdateException as e:
except RoomNotFoundException:
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)
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
@@ -218,21 +228,9 @@ class LiveKitEventsService:
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
@staticmethod
def _is_connection_test_room(room_name: str) -> bool:
"""Return True for ephemeral rooms created by the connection test endpoint."""
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
if self._is_connection_test_room(data.room.name):
logger.info(
"Ignoring room_started event for connection test room '%s'.",
data.room.name,
)
return
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
@@ -258,13 +256,6 @@ class LiveKitEventsService:
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
if self._is_connection_test_room(data.room.name):
logger.info(
"Ignoring room_finished event for connection test room '%s'.",
data.room.name,
)
return
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
+29 -3
View File
@@ -8,6 +8,7 @@ from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
ListRoomsRequest,
TwirpError,
UpdateRoomMetadataRequest,
)
@@ -29,20 +30,45 @@ class RoomManagement:
"""Service for managing LiveKit rooms."""
@async_to_sync
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
"""Update a LiveKit room's metadata.
async def update_metadata(
self,
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
(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()
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(
UpdateRoomMetadataRequest(
room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "",
metadata=json.dumps(updated_metadata),
)
)
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
return WorkerServiceMediator(mock_worker_service)
@mock.patch("core.utils.update_room_metadata")
def test_start_recording_success(
mock_update_room_metadata, mediator, mock_worker_service
):
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
"""Test successful recording start"""
# Setup
worker_id = "test-worker-123"
@@ -60,7 +58,7 @@ def test_start_recording_success(
assert mock_recording.worker_id == worker_id
assert mock_recording.status == RecordingStatusChoices.ACTIVE
mock_update_room_metadata.assert_called_once_with(
mock_update_metadata.assert_called_once_with(
str(mock_recording.room.id),
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
)
@@ -69,9 +67,9 @@ def test_start_recording_success(
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_mediator_start_recording_worker_errors(
mock_update_room_metadata, mediator, mock_worker_service, error_class
mock_update_metadata, mediator, mock_worker_service, error_class
):
"""Test handling of various worker errors during start"""
# Setup
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
assert mock_recording.worker_id is None
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
RecordingStatusChoices.ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_mediator_start_recording_from_forbidden_status(
mock_update_room_metadata, mediator, mock_worker_service, status
mock_update_metadata, mediator, mock_worker_service, status
):
"""Test handling of various worker errors during start"""
# Setup
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
mock_recording.refresh_from_db()
assert mock_recording.status == status
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
def test_mediator_stop_recording_success(mediator, mock_worker_service):
@@ -6,8 +6,6 @@ Test LiveKitEvents service.
import uuid
from unittest import mock
from django.test.utils import override_settings
import pytest
from livekit.api import EgressStatus
@@ -22,8 +20,9 @@ from core.services.livekit_events import (
api,
)
from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import MetadataUpdateException, NotificationError
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -72,9 +71,9 @@ def test_initialization(
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_success(
mock_update_room_metadata, mock_notify, mode, notification_type, service
mock_update_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording and notifies all participant."""
@@ -88,8 +87,8 @@ def test_handle_egress_ended_success(
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
recording.refresh_from_db()
@@ -106,9 +105,9 @@ def test_handle_egress_ended_success(
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_success(
mock_update_room_metadata, egress_status, status, service
mock_update_metadata, egress_status, status, service
):
"""Should successfully update room's metadata."""
@@ -119,7 +118,7 @@ def test_handle_egress_updated_success(
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_called_once_with(
mock_update_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": status}
)
@@ -131,9 +130,9 @@ def test_handle_egress_updated_success(
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_non_handled(
mock_update_room_metadata, egress_status, service
mock_update_metadata, egress_status, service
):
"""Should ignore certain egress status and don't trigger metadata updates."""
@@ -144,7 +143,7 @@ def test_handle_egress_updated_non_handled(
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
@@ -155,9 +154,9 @@ def test_handle_egress_updated_non_handled(
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service
mock_update_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop and save recording when metadata's update fails."""
@@ -166,7 +165,7 @@ def test_handle_egress_ended_metadata_update_fails(
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
service._handle_egress_ended(mock_data)
@@ -180,9 +179,9 @@ def test_handle_egress_ended_metadata_update_fails(
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_notification_fails(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should raise ActionFailedError when notification fails but still stop recording."""
@@ -202,15 +201,15 @@ def test_handle_egress_ended_notification_fails(
recording.refresh_from_db()
assert recording.status == "stopped"
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_found(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should raise ActionFailedError when recording doesn't exist."""
@@ -225,16 +224,16 @@ def test_handle_egress_ended_recording_not_found(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_not_called()
mock_update_metadata.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_active(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should ignore non-active recordings."""
@@ -246,8 +245,8 @@ def test_handle_egress_ended_recording_not_active(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
recording.refresh_from_db()
@@ -255,9 +254,9 @@ def test_handle_egress_ended_recording_not_active(
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_not_limit_reached(
mock_update_room_metadata, mock_notify, service
mock_update_metadata, mock_notify, service
):
"""Should ignore egress non-limit-reached statuses."""
@@ -269,16 +268,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
)
assert recording.status == "stopped"
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
mock_update_room_metadata, mock_collector_class, service, settings
mock_update_metadata, mock_collector_class, service, settings
):
"""Should call MetadataCollectorService.stop when it exists."""
settings.METADATA_COLLECTOR_ENABLED = True
@@ -308,7 +307,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
],
)
@mock.patch("core.services.livekit_events.MetadataCollectorService")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
_, mock_collector_class, metadata_enabled, options, service, settings
): # pylint: disable=too-many-arguments,too-many-positional-arguments
@@ -337,7 +336,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize(
"egress_status",
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
@@ -347,7 +346,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
mock_update_room_metadata,
mock_update_metadata,
mock_notify,
mock_notify_external_services,
notify_return_value,
@@ -380,7 +379,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
@pytest.mark.parametrize(
"egress_status, expected_status",
[
@@ -389,7 +388,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
],
)
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
mock_update_room_metadata,
mock_update_metadata,
mock_notify,
mock_notify_external_services,
egress_status,
@@ -426,9 +425,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
EgressStatus.EGRESS_ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_room_metadata, egress_status, service, settings
mock_update_metadata, egress_status, service, settings
):
"""Shouldn't save on invalid status."""
settings.RECORDING_STORAGE_EVENT_ENABLE = False
@@ -447,9 +446,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
@pytest.mark.parametrize(
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
)
@mock.patch("core.utils.update_room_metadata")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_ignores_non_savable_recording(
mock_update_room_metadata, status, service, settings
mock_update_metadata, status, service, settings
):
"""Should handle non-savable recordings idempotently without raising.
@@ -552,23 +551,6 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_clear_cache.assert_not_called()
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_ignores_connection_test_room(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should ignore room_finished events for connection test rooms."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_not_called()
mock_clear_cache.assert_not_called()
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
mock_data = mock.MagicMock()
@@ -619,15 +601,6 @@ def test_handle_room_started_raises_error_for_invalid_room_name(service):
service._handle_room_started(mock_data)
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
def test_handle_room_started_ignores_connection_test_room(service, settings):
"""Should ignore room_started events for connection test rooms."""
mock_data = mock.MagicMock()
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
service._handle_room_started(mock_data)
def test_handle_room_started_raises_error_for_nonexistent_room(service):
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
mock_data = mock.MagicMock()
@@ -1,66 +0,0 @@
"""Test connection test API endpoint."""
import uuid
from django.test.utils import override_settings
import jwt
import pytest
from rest_framework.test import APIClient
from core.api.connection_test import CONNECTION_TEST_USERNAME
pytestmark = pytest.mark.django_db
@override_settings(
CONNECTION_TEST_TOKEN_TTL_SECONDS=600,
CONNECTION_TEST_ROOM_PREFIX="connection-test-",
)
def test_api_connection_test_returns_ephemeral_livekit_config():
"""Each request gets a dedicated room and a short-lived token."""
client = APIClient()
response_a = client.get("/api/v1.0/connection-test/")
response_b = client.get("/api/v1.0/connection-test/")
assert response_a.status_code == 200
assert response_b.status_code == 200
data_a = response_a.json()
data_b = response_b.json()
room_a = data_a["livekit"]["room"]
room_b = data_b["livekit"]["room"]
assert room_a.startswith("connection-test-")
assert room_b.startswith("connection-test-")
uuid.UUID(room_a.removeprefix("connection-test-"))
uuid.UUID(room_b.removeprefix("connection-test-"))
assert room_a != room_b
assert data_a["livekit"]["url"]
assert data_a["livekit"]["token"]
assert data_a["livekit"]["expires_in"] == 600
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
@override_settings(CONNECTION_TEST_TOKEN_TTL_SECONDS=300)
def test_api_connection_test_token_is_short_lived_for_user(settings):
"""Connection test tokens expire quickly for users."""
client = APIClient()
response = client.get("/api/v1.0/connection-test/")
assert response.status_code == 200
config = response.json()["livekit"]
payload = jwt.decode(
config["token"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
algorithms=["HS256"],
options={"verify_exp": False},
)
assert config["expires_in"] == 300
assert payload["video"]["room"] == config["room"]
assert payload["name"] == CONNECTION_TEST_USERNAME
assert payload["video"]["roomAdmin"] is False
assert payload["exp"] - payload["nbf"] == 300
-6
View File
@@ -8,7 +8,6 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
from core.addons import viewsets as addons_viewsets
from core.api import get_frontend_configuration, viewsets
from core.api.connection_test import get_connection_test_config
from core.external_api import viewsets as external_viewsets
# - Main endpoints
@@ -47,11 +46,6 @@ urlpatterns = [
*router.urls,
*oidc_urls,
path("config/", get_frontend_configuration, name="config"),
path(
"connection-test/",
get_connection_test_config,
name="connection_test",
),
]
),
),
-57
View File
@@ -12,7 +12,6 @@ import mimetypes
import random
import secrets
import string
from datetime import timedelta
from functools import lru_cache
from typing import List, Optional
from uuid import uuid4
@@ -32,7 +31,6 @@ from livekit.api import ( # pylint: disable=E0611
LiveKitAPI,
SendDataRequest,
TwirpError,
UpdateRoomMetadataRequest,
VideoGrants,
)
@@ -69,7 +67,6 @@ def generate_token(
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
participant_id: Optional[str] = None,
ttl: Optional[timedelta] = None,
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -85,7 +82,6 @@ def generate_token(
is_admin_or_owner (bool): Whether user has admin privileges
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
Returns:
str: The LiveKit JWT access token.
@@ -134,8 +130,6 @@ def generate_token(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
)
if ttl is not None:
token = token.with_ttl(ttl)
return token.to_jwt()
@@ -263,57 +257,6 @@ async def notify_participants(room_name: str, notification_data: dict):
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
-18
View File
@@ -349,11 +349,6 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"connection_test": values.Value(
default="30/minute",
environ_name="CONNECTION_TEST_THROTTLE_RATES",
environ_prefix=None,
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
@@ -403,9 +398,6 @@ class Base(Configuration):
"feedback": values.DictValue(
{}, 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(
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
),
@@ -660,16 +652,6 @@ class Base(Configuration):
environ_prefix=None,
default=False,
)
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
600,
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
environ_prefix=None,
)
CONNECTION_TEST_ROOM_PREFIX = values.Value(
"connection-test-",
environ_name="CONNECTION_TEST_ROOM_PREFIX",
environ_prefix=None,
)
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
-4
View File
@@ -114,10 +114,6 @@ const config: Config = {
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
},
},
overlayIn: {
from: { opacity: 0 },
to: { opacity: 0.6 },
},
},
tokens: defineTokens({
/* we take a few things from the panda preset but for now we clear out some stuff.
-1
View File
@@ -20,7 +20,6 @@ export interface ApiConfig {
feedback: {
url: string
}
documentation_url?: string
external_home_url?: string
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
+49 -36
View File
@@ -7,20 +7,36 @@ const avatar = cva({
color: 'white',
display: 'flex',
borderRadius: '50%',
justifyContent: 'center',
alignItems: 'center',
userSelect: 'none',
cursor: 'default',
flexGrow: 0,
flexShrink: 0,
overflow: 'hidden',
},
variants: {
context: {
subtitles: { width: '40px', height: '40px' },
list: { width: '32px', height: '32px' },
placeholder: { width: '100%', height: '100%' },
subtitles: {
width: '40px',
height: '40px',
fontSize: '1.3rem',
lineHeight: '1rem',
},
list: {
width: '32px',
height: '32px',
fontSize: '1.3rem',
lineHeight: '1rem',
},
placeholder: {
width: '100%',
height: '100%',
},
},
notification: {
true: { border: '2px solid white' },
true: {
border: '2px solid white',
},
},
},
defaultVariants: {
@@ -33,35 +49,32 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
bgColor?: string
} & RecipeVariantProps<typeof avatar>
export const Avatar = React.memo(
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
const initial = name?.trim()?.charAt(0) ?? ''
return (
<div
style={{ backgroundColor: bgColor, ...style }}
className={avatar({ context, notification })}
{...props}
export const Avatar = ({
name,
bgColor,
context,
notification,
style,
...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
viewBox="0 0 100 100"
aria-hidden="true"
className={css({ width: '100%', height: '100%', display: 'block' })}
>
<text
x="50"
y="50"
textAnchor="middle"
dominantBaseline="central"
fontSize="52"
fontWeight="500"
fill="currentColor"
>
{initial}
</text>
</svg>
</div>
)
}
)
Avatar.displayName = 'Avatar'
{initial}
</span>
</div>
)
}
@@ -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>
)
}
@@ -1,13 +0,0 @@
import { fetchApi } from '@/api/fetchApi'
export type ConnectionTestTokenResponse = {
livekit: {
url: string
room: string
token: string
expires_in: number
}
}
export const fetchConnectionTestToken = () =>
fetchApi<ConnectionTestTokenResponse>('/connection-test/')
@@ -1,245 +0,0 @@
import { useRef, useState } from 'react'
import {
CheckStatus,
ConnectionCheck,
createLocalAudioTrack,
createLocalVideoTrack,
getBrowser,
type CheckInfo,
type LocalVideoTrack,
} from 'livekit-client'
import { fetchConnectionTestToken } from '../api/fetchConnectionTestToken'
import {
createInitialSteps,
type ConnectionTestLog,
type ConnectionTestStepId,
type ConnectionTestStepResult,
type ConnectionTestStepStatus,
} from '../types'
import { openPermissionsDialog } from '@/stores/permissions'
const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
'websocket',
'webrtc',
'turn',
'reconnect',
'publishAudio',
'publishVideo',
]
const CHECK_STATUS_TO_STEP: Record<CheckStatus, ConnectionTestStepStatus> = {
[CheckStatus.IDLE]: 'pending',
[CheckStatus.RUNNING]: 'running',
[CheckStatus.SUCCESS]: 'success',
[CheckStatus.FAILED]: 'failed',
[CheckStatus.SKIPPED]: 'skipped',
}
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
error instanceof Error ? error.message : fallback
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
summary: info.description,
logs: info.logs,
})
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
const grouped: Record<MediaDeviceKind, string[]> = {
audioinput: [],
audiooutput: [],
videoinput: [],
}
for (const device of devices) {
grouped[device.kind].push(device.label || device.deviceId)
}
return grouped
}
export const useConnectionTestRunner = () => {
const [steps, setSteps] = useState(createInitialSteps)
const [isRunning, setIsRunning] = useState(false)
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack | null>(null)
const videoTrackRef = useRef<LocalVideoTrack | null>(null)
const abortRef = useRef<AbortController | null>(null)
const updateStep = (
id: ConnectionTestStepId,
patch: Partial<ConnectionTestStepResult>
) => {
setSteps((current) =>
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
)
}
const stopVideoTrack = () => {
videoTrackRef.current?.stop()
videoTrackRef.current = null
setVideoTrack(null)
}
const skipSteps = (
ids: ConnectionTestStepId[],
summary: string,
logs?: ConnectionTestLog[]
) => {
for (const id of ids) {
updateStep(id, { status: 'skipped', summary, logs })
}
}
/** Returns true on success, false on failure, null if aborted. */
const runStep = async (
id: ConnectionTestStepId,
signal: AbortSignal,
fn: () => Promise<Partial<ConnectionTestStepResult>>
): Promise<boolean | null> => {
if (signal.aborted) return null
updateStep(id, { status: 'running', summary: undefined, logs: undefined })
try {
const result = await fn()
if (signal.aborted) return null
// `result.status` overrides when set (LiveKit checks map their own status)
updateStep(id, { status: 'success', ...result })
return true
} catch (error) {
if (signal.aborted) return null
updateStep(id, {
status: 'failed',
summary: getErrorMessage(error),
})
return false
}
}
const runTest = async () => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const { signal } = controller
setIsRunning(true)
setSteps(createInitialSteps())
stopVideoTrack()
try {
await runStep('browser', signal, async () => {
const browser = getBrowser()
if (!browser) throw new Error('Browser not detected')
return {
summary: `${browser.name} ${browser.version}`,
data: {
name: browser.name,
version: browser.version,
os: browser.os,
osVersion: browser.osVersion,
},
}
})
if (signal.aborted) return
const microphoneOk = await runStep('microphone', signal, async () => {
const track = await createLocalAudioTrack()
const label =
track.mediaStreamTrack.label ||
track.mediaStreamTrack.getSettings().deviceId ||
''
track.stop()
return { summary: label, data: { label } }
})
if (signal.aborted) return
if (!microphoneOk) openPermissionsDialog('audioinput')
const cameraOk = await runStep('camera', signal, async () => {
const track = await createLocalVideoTrack()
videoTrackRef.current = track
setVideoTrack(track)
const settings = track.mediaStreamTrack.getSettings()
const label = track.mediaStreamTrack.label || ''
return {
summary: label,
data: {
label,
width: settings.width,
height: settings.height,
},
}
})
if (signal.aborted) return
if (!cameraOk) openPermissionsDialog('videoinput')
await runStep('devices', signal, async () => {
const devices = await navigator.mediaDevices.enumerateDevices()
return {
summary: String(devices.length),
data: groupDevicesByKind(devices),
}
})
if (signal.aborted) return
let checker: ConnectionCheck
try {
const { livekit } = await fetchConnectionTestToken()
checker = new ConnectionCheck(livekit.url, livekit.token)
} catch (error) {
skipSteps(
LIVEKIT_STEP_IDS,
getErrorMessage(error, 'Failed to fetch test token')
)
return
}
await runStep('websocket', signal, async () =>
fromCheckInfo(await checker.checkWebsocket())
)
await runStep('webrtc', signal, async () =>
fromCheckInfo(await checker.checkWebRTC())
)
await runStep('turn', signal, async () =>
fromCheckInfo(await checker.checkTURN())
)
await runStep('reconnect', signal, async () =>
fromCheckInfo(await checker.checkReconnect())
)
if (!microphoneOk) {
skipSteps(['publishAudio'], 'Microphone permission required')
} else {
await runStep('publishAudio', signal, async () =>
fromCheckInfo(await checker.checkPublishAudio())
)
}
if (!cameraOk) {
skipSteps(['publishVideo'], 'Camera permission required')
} else {
stopVideoTrack()
await runStep('publishVideo', signal, async () =>
fromCheckInfo(await checker.checkPublishVideo())
)
}
} finally {
if (!signal.aborted) {
stopVideoTrack()
setIsRunning(false)
}
}
}
const reset = () => {
abortRef.current?.abort()
stopVideoTrack()
setSteps(createInitialSteps())
setIsRunning(false)
}
return {
steps,
isRunning,
videoTrack,
runTest,
reset,
}
}
@@ -1,156 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CenteredContent } from '@/layout/CenteredContent'
import { Screen } from '@/layout/Screen'
import { Box, Button, Text, Ul } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { Center, HStack, VStack } from '@/styled-system/jsx'
import { Permissions } from '@/features/rooms/components/Permissions'
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
import type { ConnectionTestStepId, ConnectionTestStepResult } from '../types'
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
const TestStepItem = ({ step }: { step: ConnectionTestStepResult }) => {
const { t } = useTranslation('connectionTest')
const [showDetails, setShowDetails] = useState(false)
const hasLogs = Boolean(step.logs?.length)
const statusLabel = t(`status.${step.status}`)
const stepLabel = t(`steps.${step.id as ConnectionTestStepId}`)
const statusVariant =
step.status === 'failed'
? 'warning'
: step.status === 'success'
? 'body'
: 'smNote'
return (
<VStack gap="0.25rem" alignItems="stretch" width="100%">
<HStack
justifyContent="space-between"
alignItems="flex-start"
width="100%"
>
<Text variant="bodyXsMedium">{stepLabel}</Text>
{step.status === 'running' ? (
<Spinner size={20} />
) : (
<Text variant={statusVariant}>{statusLabel}</Text>
)}
</HStack>
{step.summary && (
<Text variant="smNote" margin={false}>
{step.summary}
</Text>
)}
{hasLogs && step.status !== 'pending' && step.status !== 'running' && (
<Button
variant="secondaryText"
size="sm"
onPress={() => setShowDetails((open) => !open)}
>
{t('details')}
</Button>
)}
{showDetails && step.logs && (
<Ul>
{step.logs.map((log, index) => (
<li key={index}>
<Text variant="xsNote" as="span">
{log.message}
</Text>
</li>
))}
</Ul>
)}
</VStack>
)
}
const ConnectionTest = () => {
const { t } = useTranslation('connectionTest')
const { steps, isRunning, videoTrack, runTest } = useConnectionTestRunner()
const videoRef = useRef<HTMLVideoElement>(null)
const hasStarted = steps.some((step) => step.status !== 'pending')
const hasFailed = steps.some((step) => step.status === 'failed')
const isPublishVideoRunning = steps.some(
(step) => step.id === 'publishVideo' && step.status === 'running'
)
useEffect(() => {
const element = videoRef.current
if (!element || !videoTrack) return
videoTrack.attach(element)
return () => {
videoTrack.detach(element)
}
}, [videoTrack])
// LiveKit appends a bare <video> to document.body during publishVideo.
// Keep it in the DOM (so the frame check still works) but hide it visually.
useEffect(() => {
document.body.classList.toggle(
HIDE_LIVEKIT_VIDEO_CLASS,
isPublishVideoRunning
)
return () => {
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
}
}, [isPublishVideoRunning])
return (
<Screen layout="centered">
<Permissions />
<CenteredContent title={t('title')} withBackButton>
<Center>
<VStack gap="1.5rem" maxWidth="36rem" width="100%">
<Text as="p" variant="paragraph" centered last>
{t('intro')}
</Text>
<Button variant="primary" onPress={runTest} isDisabled={isRunning}>
{hasStarted ? t('reset') : t('runTest')}
</Button>
{videoTrack && (
<Center>
<video ref={videoRef} autoPlay playsInline muted />
</Center>
)}
{hasStarted && (
<Box variant="light" width="100%">
<VStack gap="1rem" alignItems="stretch">
{steps.map((step) => (
<TestStepItem key={step.id} step={step} />
))}
</VStack>
</Box>
)}
{hasFailed && !isRunning && (
<Text variant="smNote" centered>
{t('help.firewall')}
</Text>
)}
{hasStarted && !isRunning && (
<Button
variant="secondary"
onPress={() => downloadConnectionTestReport(steps)}
>
{t('downloadReport')}
</Button>
)}
</VStack>
</Center>
</CenteredContent>
</Screen>
)
}
export default ConnectionTest
@@ -1,47 +0,0 @@
export type ConnectionTestStepId =
| 'browser'
| 'microphone'
| 'camera'
| 'devices'
| 'websocket'
| 'webrtc'
| 'turn'
| 'reconnect'
| 'publishAudio'
| 'publishVideo'
export type ConnectionTestStepStatus =
| 'pending'
| 'running'
| 'success'
| 'failed'
| 'skipped'
export type ConnectionTestLog = {
level: 'info' | 'warning' | 'error'
message: string
}
export type ConnectionTestStepResult = {
id: ConnectionTestStepId
status: ConnectionTestStepStatus
summary?: string
logs?: ConnectionTestLog[]
data?: Record<string, unknown>
}
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] = [
'browser',
'microphone',
'camera',
'devices',
'websocket',
'webrtc',
'turn',
'reconnect',
'publishAudio',
'publishVideo',
]
export const createInitialSteps = (): ConnectionTestStepResult[] =>
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
@@ -1,49 +0,0 @@
import type { ConnectionTestStepResult } from '../types'
export type ConnectionTestReport = {
generatedAt: string
userAgent: string
steps: Record<
string,
{
status: ConnectionTestStepResult['status']
summary?: string
logs?: ConnectionTestStepResult['logs']
data?: ConnectionTestStepResult['data']
}
>
}
export const buildConnectionTestReport = (
steps: ConnectionTestStepResult[]
): ConnectionTestReport => ({
generatedAt: new Date().toISOString(),
userAgent: navigator.userAgent,
steps: Object.fromEntries(
steps.map(({ id, status, summary, logs, data }) => [
id,
{
status,
...(summary !== undefined ? { summary } : {}),
...(logs?.length ? { logs } : {}),
...(data !== undefined ? { data } : {}),
},
])
),
})
export const downloadConnectionTestReport = (
steps: ConnectionTestStepResult[]
) => {
const report = buildConnectionTestReport(steps)
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
const blob = new Blob([JSON.stringify(report, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `connection-test-${timestamp}.json`
anchor.click()
URL.revokeObjectURL(url)
}
@@ -3,7 +3,6 @@ import { getScrollBarWidth } from '@livekit/components-core'
import * as React from 'react'
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useCallback, useEffect, useLayoutEffect } from 'react'
const MIN_HEIGHT = 130
const MIN_WIDTH = 140
@@ -11,72 +10,6 @@ const MIN_VISIBLE_TILES = 1
const ASPECT_RATIO = 16 / 10
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 */
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
tracks: TrackReferenceOrPlaceholder[]
@@ -107,42 +40,52 @@ export function CarouselLayout({
...props
}: CarouselLayoutProps) {
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>({
orientation: orientation ?? 'vertical',
maxVisibleTiles: MIN_VISIBLE_TILES,
})
const tileSpan =
carouselOrientation === 'vertical'
? 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
// derived layout genuinely changed, not on every resize tick.
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
setLayout((prev) =>
prev.orientation === next.orientation &&
prev.maxVisibleTiles === next.maxVisibleTiles
? prev
: next
)
}, [])
const tilesThatFit =
carouselOrientation === 'vertical'
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
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 (
<>
<CarouselLayoutObserver
asideEl={asideEl}
orientation={orientation}
onLayoutChange={handleLayoutChange}
/>
{/* `key` intentionally remounts the container when orientation flips, */}
{/* which resets scroll position and re-runs the observer measurement. */}
<aside
key={layout.orientation}
className="lk-carousel"
ref={asideEl}
{...props}
>
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
</aside>
</>
<aside
key={carouselOrientation}
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 { useGridLayout } from '../hooks/useGridLayout'
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 */
export interface GridLayoutProps
@@ -67,15 +37,14 @@ export interface GridLayoutProps
* @public
*/
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
const gridEl = useRef<HTMLDivElement>(null)
const [maxTiles, setMaxTiles] = useState(1)
const gridEl = React.createRef<HTMLDivElement>()
const elementProps = React.useMemo(
() => mergeProps(props, { className: 'lk-grid-layout' }),
[props]
)
const pagination = usePagination(maxTiles, tracks)
useSpeakerPromotionTrigger(pagination.tracks)
const { layout } = useGridLayout(gridEl, tracks.length)
const pagination = usePagination(layout.maxTiles, tracks)
useSwipe(gridEl, {
onLeftSwipe: pagination.nextPage,
@@ -88,13 +57,8 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
data-lk-pagination={pagination.totalPageCount > 1}
{...elementProps}
>
<GridLayoutObserver
gridEl={gridEl}
trackCount={tracks.length}
onMaxTilesChange={setMaxTiles}
/>
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
{tracks.length > maxTiles && (
{tracks.length > layout.maxTiles && (
<>
<PaginationIndicator
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])
}
@@ -4,13 +4,13 @@ import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client'
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 { HStack, styled } from '@/styled-system/jsx'
import { Div } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer'
import { setPinnedTrack } from '@/stores/layout'
const ClickableToast = styled(RACButton, {
base: {
@@ -28,6 +28,7 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
state,
ref
)
const layoutContext = useMaybeLayoutContext()
const participant = props.toast.content.participant
if (!participant) return
@@ -37,13 +38,18 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
const pinParticipant = () => {
layoutContext?.pin.dispatch?.({
msg: 'set_pin',
trackReference,
})
}
return (
<StyledToastContainer {...toastProps} ref={ref}>
<ClickableToast
ref={ref}
onPress={(e) => {
setPinnedTrack(trackReference)
pinParticipant()
closeButtonProps.onPress?.(e)
}}
>
@@ -7,8 +7,8 @@ import { RiInfinityLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useEffect, useRef, useState } from 'react'
import { usePrevious } from '@/hooks/usePrevious'
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useNotificationSound } from '../hooks/useSoundNotification'
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>
)
}
@@ -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'
@@ -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>
)
}
@@ -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}
/>
</>
)
}
@@ -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>
)
}
@@ -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,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,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,7 +1,7 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
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'
type PipFocusLayoutProps = {
@@ -1,7 +1,7 @@
import { memo, useMemo, useRef } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
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 { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '../../utils/pipTrackSelection'
@@ -1,7 +1,7 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
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'
type PipScreenShareLayoutProps = {
@@ -1,21 +1,18 @@
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
import { useTrackToggle } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { useLocalParticipant } from '@livekit/components-react'
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
const { enabled: isScreenSharing } = useTrackToggle({
source: Track.Source.ScreenShare,
})
const { localParticipant } = useLocalParticipant()
return (
<Container
role="region"
aria-label={t('stage')}
{...(!isScreenSharing ? { inert: '' } : {})}
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
>
{children}
</Container>
@@ -23,6 +23,7 @@ import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox.tsx'
import {
useSettingsDialog,
SettingsDialogExtendedKey,
useTranscriptionLanguage,
} from '@/features/settings'
@@ -34,7 +35,6 @@ import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnab
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { LimitDescription } from './LimitDescription'
import { openSettingsDialog } from '@/stores/settings'
export const TranscriptSidePanel = () => {
const { data } = useConfig()
@@ -48,6 +48,8 @@ export const TranscriptSidePanel = () => {
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
useTranscriptionLanguage()
const { openSettingsDialog } = useSettingsDialog()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
@@ -1,4 +1,3 @@
import React from 'react'
import { styled } from '@/styled-system/jsx'
const StyledContainer = styled('div', {
@@ -68,36 +67,35 @@ export type ActiveSpeakerProps = {
pushToTalk?: boolean
}
export const ActiveSpeaker = React.memo(
({ isSpeaking, pushToTalk }: ActiveSpeakerProps) => {
return (
<StyledContainer pushToTalk={pushToTalk}>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
size="small"
style={{
animationDelay: '300ms',
}}
/>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
style={{
animationDelay: '100ms',
}}
/>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
size="small"
style={{
animationDelay: '500ms',
}}
/>
</StyledContainer>
)
}
)
ActiveSpeaker.displayName = 'ActiveSpeaker'
export const ActiveSpeaker = ({
isSpeaking,
pushToTalk,
}: ActiveSpeakerProps) => {
return (
<StyledContainer pushToTalk={pushToTalk}>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
size="small"
style={{
animationDelay: '300ms',
}}
/>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
style={{
animationDelay: '100ms',
}}
/>
<StyledChild
pushToTalk={pushToTalk}
active={isSpeaking}
size="small"
style={{
animationDelay: '500ms',
}}
/>
</StyledContainer>
)
}
@@ -170,6 +170,7 @@ export const Conference = ({
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
@@ -302,7 +303,13 @@ export const Conference = ({
}}
>
<VideoConference />
{!isMobile && <InviteDialog mode={mode} />}
{showInviteDialog && !isMobile && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
onClose={() => setShowInviteDialog(false)}
/>
)}
<MediaDeviceErrorAlert
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
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 { Heading, Dialog } from 'react-aria-components'
import { Text, text } from '@/primitives/Text'
@@ -10,7 +10,7 @@ import {
RiFileCopyLine,
RiSpam2Fill,
} from '@remixicon/react'
import { useMemo, useState } from 'react'
import { useMemo } from 'react'
import { css } from '@/styled-system/css'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
@@ -39,9 +39,7 @@ const StyledRACDialog = styled(Dialog, {
},
})
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData()
@@ -60,161 +58,163 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(roomData)
if (!showInviteDialog) return null
return (
<StyledRACDialog>
<VStack
alignItems="left"
justify="start"
gap={0}
style={{ maxWidth: '100%', overflow: 'visible' }}
>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
invisible
variant="tertiaryText"
size="xs"
onPress={() => {
setShowInviteDialog(false)
}}
aria-label={t('closeDialog')}
>
<RiCloseLine />
</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',
})}
<StyledRACDialog {...props}>
{({ close }) => (
<VStack
alignItems="left"
justify="start"
gap={0}
style={{ maxWidth: '100%', overflow: 'visible' }}
>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
{t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
<Button
invisible
variant="tertiaryText"
size="xs"
onPress={() => {
props.onClose?.()
close()
}}
aria-label={t('closeDialog')}
>
<Text as="p" wrap="pretty">
{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>
<RiCloseLine />
</Button>
</Div>
<P>{t('description')}</P>
{isTelephonyReadyForUse ? (
<div
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
marginTop: '0.5rem',
gap: '1rem',
overflow: 'visible',
})}
>
<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>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<Text as="p" wrap="pretty">
{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
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
variant={isCopied ? 'success' : 'tertiary'}
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"
/>
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
{t('copied')}
</>
) : (
<>
<RiFileCopyLine
style={{ marginRight: '6px', minWidth: '18px' }}
aria-hidden="true"
/>
{t('copy')}
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
{t('copyUrl')}
</>
)}
</Button>
</div>
) : (
<Button
variant={isCopied ? 'success' : 'tertiary'}
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}
)}
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
<HStack>
<div
className={css({
fill: 'primary.500',
backgroundColor: 'primary.200',
borderRadius: '50%',
padding: '4px',
marginTop: '1rem',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
</VStack>
>
<RiSpam2Fill
size={22}
className={css({
fill: 'primary.500',
})}
/>
</div>
<Text variant="sm" style={{ marginTop: '1rem' }}>
{t('permissions')}
</Text>
</HStack>
)}
</VStack>
)}
</StyledRACDialog>
)
}
@@ -7,7 +7,7 @@ import { useEnterRoom } from '../api/enterRoom'
import {
useListWaitingParticipants,
type WaitingParticipant,
} from '../../participants/api/listWaitingParticipants'
} from '../api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
@@ -1,4 +1,4 @@
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
import { ParticipantTile } from './ParticipantTile'
import type { FocusLayoutProps } from '@livekit/components-react'
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
@@ -3,10 +3,10 @@ import { Button, Text } from '@/primitives'
import { useCallback, useMemo, useRef } from 'react'
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
import { useSnapshot } from 'valtio'
import { useLocalParticipant } from '@livekit/components-react'
import { useSize } from '../hooks/useResizeObserver'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { LocalParticipant } from 'livekit-client'
export const FullScreenShareWarning = ({
trackReference,
@@ -17,6 +17,7 @@ export const FullScreenShareWarning = ({
const warningContainerRef = useRef<HTMLDivElement>(null)
const { width: containerWidth } = useSize(warningContainerRef)
const { localParticipant } = useLocalParticipant()
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
const isFullScreenSharing = useMemo(() => {
@@ -56,10 +57,8 @@ export const FullScreenShareWarning = ({
screenSharePreferences.enabled && isFullScreenSharing
const handleStopScreenShare = async () => {
const participant = trackReference.participant
if (!(participant instanceof LocalParticipant)) return
if (!participant.isScreenShareEnabled) return
await (participant as LocalParticipant).setScreenShareEnabled(false, {}, {})
if (!localParticipant.isScreenShareEnabled) return
await localParticipant.setScreenShareEnabled(false, {}, {})
}
const handleDismissWarning = useCallback(() => {
@@ -7,9 +7,9 @@ import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
import { openSettingsDialog } from '@/stores/settings'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
@@ -94,6 +94,7 @@ const Description = () => {
const Settings = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
const { openSettingsDialog } = useSettingsDialog()
return (
<P>
{t('settingsPrefix')}{' '}
@@ -1,4 +1,4 @@
import React from 'react'
import React, { ReactNode } from 'react'
import { styled } from '@/styled-system/jsx'
const Hint = styled('div', {
@@ -25,17 +25,15 @@ const Hint = styled('div', {
})
export interface KeyboardShortcutHintProps {
hint?: string
children: ReactNode
}
/**
* Small reusable bubble used to display and announce keyboard shortcuts,
* typically when an element receives keyboard focus.
*/
export const KeyboardShortcutHint = React.memo(
({ hint }: KeyboardShortcutHintProps) => {
return <Hint>{hint}</Hint>
}
)
KeyboardShortcutHint.displayName = 'KeyboardShortcutHint'
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
children,
}) => {
return <Hint>{children}</Hint>
}
@@ -1,8 +1,8 @@
import { Menu as RACMenu } from 'react-aria-components'
import type { Participant } from 'livekit-client'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { PinMenuItem } from './items/PinMenuItem'
import { RemoveMenuItem } from './items/RemoveMenuItem'
import { PinMenuItem } from './PinMenuItem'
import { RemoveMenuItem } from './RemoveMenuItem'
export const ParticipantMenu = ({
participant,
@@ -1,28 +1,15 @@
import { Participant, Track } from 'livekit-client'
import type { Participant } from 'livekit-client'
import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx'
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { isEqualTrackRef } from '@livekit/components-core'
import Source = Track.Source
import { useMemo } from 'react'
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
const trackRef = useMemo(() => {
return {
participant: participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
}, [participant])
const { pinnedTrackRef } = useSnapshot(layoutStore)
const inFocus = isEqualTrackRef(pinnedTrackRef, trackRef)
const { toggle, inFocus } = useFocusToggleParticipant(participant)
return (
<MenuItem
@@ -30,7 +17,7 @@ export const PinMenuItem = ({ participant }: { participant: Participant }) => {
name: participant.name,
})}
className={menuRecipe({ icon: true }).item}
onAction={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
onAction={toggle}
>
<HStack gap={0.25}>
{inFocus ? (
@@ -0,0 +1,44 @@
import type { CSSProperties } from 'react'
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useParticipantInfo } from '@livekit/components-react'
import type { Participant } from 'livekit-client'
const participantNameStyles: CSSProperties = {
paddingBottom: '0.1rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
const participantNameScreenShareStyles: CSSProperties = {
...participantNameStyles,
marginLeft: '0.4rem',
}
export const ParticipantName = ({
participant,
isScreenShare = false,
}: {
participant: Participant
isScreenShare: boolean
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
const { identity, name } = useParticipantInfo({ participant })
const displayedName = name != '' ? name : identity
if (isScreenShare) {
return (
<Text variant="sm" style={participantNameScreenShareStyles}>
{t('screenShare', { name: displayedName })}
</Text>
)
}
return (
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
{displayedName}
</Text>
)
}
@@ -0,0 +1,73 @@
import type { Participant } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar'
import { useIsSpeaking } from '@livekit/components-react'
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useMemo, useRef } from 'react'
const StyledParticipantPlaceHolder = styled('div', {
base: {
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
})
type ParticipantPlaceholderProps = {
participant: Participant
}
export const ParticipantPlaceholder = ({
participant,
}: ParticipantPlaceholderProps) => {
const isSpeaking = useIsSpeaking(participant)
const participantColor = getParticipantColor(participant)
const backgroundGradient = useMemo(
() => getParticipantBackgroundGradient(participantColor),
[participantColor]
)
const placeholderEl = useRef<HTMLDivElement>(null)
const { width, height } = useSize(placeholderEl)
const minDimension = Math.min(width, height)
const avatarSize = useMemo(
() => Math.min(Math.round(minDimension * 0.9), 160),
[minDimension]
)
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
return (
<StyledParticipantPlaceHolder
ref={placeholderEl}
style={{
backgroundColor: participantColor,
backgroundImage: backgroundGradient,
}}
>
<div
style={{
borderRadius: '50%',
animation: isSpeaking ? 'pulse 1s infinite' : undefined,
height: 'auto',
aspectRatio: '1/1',
width: '80%',
maxWidth: `${avatarSize}px`,
fontSize: `${initialSize}px`,
}}
>
{/*fixme - participant doesn't update on ParticipantNameChanged event */}
<Avatar
name={participant.name}
bgColor={participantColor}
context="placeholder"
/>
</div>
</StyledParticipantPlaceHolder>
)
}
@@ -1,32 +1,39 @@
import React from 'react'
import {
AudioTrack,
ConnectionQualityIndicator,
LockLockedIcon,
ParticipantTileProps,
ScreenShareIcon,
useEnsureTrackRef,
useFeatureContext,
useIsEncrypted,
useMaybeLayoutContext,
useMaybeTrackRefContext,
useParticipantTile,
VideoTrack,
TrackRefContext,
ParticipantContextIfNeeded,
useParticipantInfo,
} from '@livekit/components-react'
import React from 'react'
import {
isEqualTrackRef,
isTrackReference,
isTrackReferencePinned,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
import { Track } from 'livekit-client'
import { RiHand } from '@remixicon/react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import { HStack } from '@/styled-system/jsx'
import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
import { ParticipantTileFocus } from './ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
import { ParticipantMetadata } from './ParticipantMetadata'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -66,12 +73,16 @@ export const ParticipantTile: (
ref
) {
const trackReference = useEnsureTrackRef(trackRef)
const { elementProps } = useParticipantTile<HTMLDivElement>({
htmlProps,
disableSpeakingIndicator,
onParticipantClick,
trackRef: trackReference,
})
const isEncrypted = useIsEncrypted(trackReference.participant)
const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription
const handleSubscribe = React.useCallback(
@@ -79,25 +90,28 @@ export const ParticipantTile: (
if (
trackReference.source &&
!subscribed &&
layoutStore.pinnedTrackRef &&
isEqualTrackRef(trackReference, layoutStore.pinnedTrackRef)
layoutContext &&
layoutContext.pin.dispatch &&
isTrackReferencePinned(trackReference, layoutContext.pin.state)
) {
clearPinnedTrack()
layoutContext.pin.dispatch({ msg: 'clear_pin' })
}
},
[trackReference]
[trackReference, layoutContext]
)
const { isHandRaised } = useRaisedHand({
participant: trackReference.participant,
})
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
participant: trackReference.participant,
})
const isScreenShare = trackReference.source != Track.Source.Camera
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantColor = getParticipantColor(trackReference.participant)
const { identity, name } = useParticipantInfo({
participant: trackReference.participant,
})
const participantName = name || identity || 'Unknown'
const participantName = getParticipantName(trackReference.participant)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = {
@@ -124,9 +138,7 @@ export const ParticipantTile: (
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
{trackReference.participant.isLocal && (
<FullScreenShareWarning trackReference={trackReference} />
)}
<FullScreenShareWarning trackReference={trackReference} />
{children ?? (
<>
{isTrackReference(trackReference) &&
@@ -148,16 +160,76 @@ export const ParticipantTile: (
)}
<div className="lk-participant-placeholder">
<ParticipantPlaceholder
color={participantColor}
displayedNamed={participantName}
participant={trackReference.participant}
/>
</div>
{!disableMetadata && (
<ParticipantMetadata
displayedName={participantName}
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
<div className="lk-participant-metadata">
<HStack gap={0.25}>
{!isScreenShare && (
<MutedMicIndicator
participant={trackReference.participant}
/>
)}
<div
className="lk-participant-metadata-item"
style={{
padding: '0.1rem 0.25rem',
backgroundColor:
isHandRaised && !isScreenShare
? firstInQueue
? '#fde047'
: 'white'
: undefined,
color:
isHandRaised && !isScreenShare ? 'black' : undefined,
transition: 'background 200ms ease, color 400ms ease',
}}
>
{isHandRaised && !isScreenShare && (
<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>
)}
{isScreenShare && (
<ScreenShareIcon
style={{
maxWidth: '20px',
width: '100%',
}}
/>
)}
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</div>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div>
)}
</>
)}
@@ -169,13 +241,13 @@ export const ParticipantTile: (
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
<KeyboardShortcutHint
hint={t('toolbarHint', {
<KeyboardShortcutHint>
{t('toolbarHint', {
shortcut: formatShortcutLabel(
getShortcutDescriptorById('open-shortcuts')?.shortcut
),
})}
/>
</KeyboardShortcutHint>
</div>
)
})
@@ -0,0 +1,232 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import {
RiFullscreenLine,
RiImageCircleAiFill,
RiMicLine,
RiMicOffLine,
RiPushpin2Line,
RiUnpinLine,
} from '@remixicon/react'
import {
useFocusToggle,
useTrackMutedIndicator,
} from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useEffect, useRef, useState } from 'react'
import { useSidePanel } from '../hooks/useSidePanel'
import { useFullScreen } from '../hooks/useFullScreen'
import { type Participant, Track } from 'livekit-client'
import { MuteAlertDialog } from './MuteAlertDialog'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
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>
)
}
const FocusButton = ({
trackRef,
}: {
trackRef: TrackReferenceOrPlaceholder
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const { mergedProps, inFocus } = useFocusToggle({
trackRef,
props: {},
})
return (
<Button
size="sm"
variant="primaryTextDark"
square
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPress={(event) => mergedProps?.onClick?.(event as any)}
>
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
</Button>
)
}
const EffectsButton = () => {
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>
)
}
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}
/>
</>
)
}
const MOUSE_IDLE_TIME = 3000
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const [hovered, setHovered] = useState(false)
const [opacity, setOpacity] = useState(0)
const idleTimerRef = useRef<number | null>(null)
const [isIdleRef, setIsIdleRef] = useState(false)
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
useEffect(() => {
if (isVisible) {
// Wait for next frame to ensure element is mounted
requestAnimationFrame(() => {
setOpacity(0.6)
})
} else {
setOpacity(0)
}
}, [isVisible])
const handleMouseMove = () => {
if (idleTimerRef.current) {
window.clearTimeout(idleTimerRef.current)
}
idleTimerRef.current = window.setTimeout(() => {
setIsIdleRef(true)
}, MOUSE_IDLE_TIME)
setIsIdleRef(false)
}
const participant = trackRef.participant
const isScreenShare = trackRef.source == Track.Source.ScreenShare
const isLocal = trackRef.participant.isLocal
const canMute = useCanMute(participant)
return (
<div
className={css({
position: 'absolute',
left: '0',
top: '0',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
})}
aria-hidden={!isVisible}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
>
{isVisible && (
<div
className={css({
backgroundColor: 'primaryDark.50',
transition: 'opacity 200ms linear',
zIndex: 1,
borderRadius: '0.25rem',
display: 'flex',
_hover: {
opacity: '0.95 !important',
},
})}
style={{ opacity }}
>
<HStack
gap={0.5}
className={css({
padding: '0.5rem',
_hover: {
opacity: '1 !important',
},
})}
>
<FocusButton trackRef={trackRef} />
{!isScreenShare ? (
<>
{participant.isLocal ? (
<EffectsButton />
) : (
canMute && <MuteButton participant={participant} />
)}
</>
) : (
!isLocal && <ZoomButton trackRef={trackRef} />
)}
</HStack>
</div>
)}
</div>
)
}
@@ -1,6 +0,0 @@
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
export const RoomMetadataSynchronizer = () => {
useSyncLiveKitMetadata()
return null
}
@@ -5,10 +5,10 @@ import { text } from '@/primitives/Text'
import { Button, Div } from '@/primitives'
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ParticipantsList } from '@/features/participants/components/ParticipantsList'
import { ParticipantsList } from './controls/Participants/ParticipantsList'
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
import React, { ReactNode, useCallback, useRef } from 'react'
import { Chat } from '@/features/chat/components/Chat'
import { Chat } from '../prefabs/Chat'
import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
@@ -218,7 +218,7 @@ export const SidePanel = () => {
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen}>
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen} keepAlive={true}>
@@ -0,0 +1,71 @@
import type { ReceivedChatMessage } from '@livekit/components-core'
import * as React from 'react'
import { css } from '@/styled-system/css'
import { Text } from '@/primitives'
import type { MessageFormatter } from '@livekit/components-react'
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
entry: ReceivedChatMessage
hideMetadata?: boolean
messageFormatter?: MessageFormatter
}
export const ChatEntry: (
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLLIElement,
ChatEntryProps
>(function ChatEntry(
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
ref
) {
const formattedMessage = React.useMemo(() => {
return messageFormatter ? messageFormatter(entry.message) : entry.message
}, [entry.message, messageFormatter])
const time = new Date(entry.timestamp)
const locale = navigator ? navigator.language : 'en-US'
return (
<li
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
})}
ref={ref}
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
{...props}
>
{!hideMetadata && (
<span
className={css({
display: 'flex',
gap: '0.5rem',
paddingTop: '0.75rem',
})}
>
<Text bold={true} variant="sm">
{entry.from?.name ?? entry.from?.identity}
</Text>
<Text variant="sm" className={css({ color: 'gray.700' })}>
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
</Text>
</span>
)}
<Text
variant="sm"
margin={false}
className={css({
whiteSpace: 'pre-wrap',
'& .lk-chat-link': {
color: 'blue',
textDecoration: 'underline',
},
})}
>
{formattedMessage}
</Text>
</li>
)
})
@@ -0,0 +1,120 @@
import { Button, TextArea } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { RiSendPlane2Fill } from '@remixicon/react'
import { useState, useEffect, RefObject } from 'react'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
const MAX_ROWS = 6
interface ChatInputProps {
inputRef: RefObject<HTMLTextAreaElement>
onSubmit: (text: string) => void
isSending: boolean
}
export const ChatInput = ({
inputRef,
onSubmit,
isSending,
}: ChatInputProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
const [text, setText] = useState('')
const [rows, setRows] = useState(1)
const handleSubmit = () => {
onSubmit(text)
setText('')
}
const isDisabled = !text.trim() || isSending
const submitOnEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
e.preventDefault()
if (!isDisabled) handleSubmit()
}
useEffect(() => {
const resize = () => {
if (!inputRef.current) return
const textAreaLineHeight = 20 // Adjust this value based on your TextArea's line height
const previousRows = inputRef.current.rows
inputRef.current.rows = 1
const currentRows = Math.floor(
inputRef.current.scrollHeight / textAreaLineHeight
)
if (currentRows === previousRows) {
inputRef.current.rows = currentRows
}
if (currentRows >= MAX_ROWS) {
inputRef.current.rows = MAX_ROWS
inputRef.current.scrollTop = inputRef.current.scrollHeight
}
if (currentRows < MAX_ROWS) {
inputRef.current.style.overflowY = 'hidden'
} else {
inputRef.current.style.overflowY = 'auto'
}
setRows(currentRows < MAX_ROWS ? currentRows : MAX_ROWS)
}
resize()
}, [text, inputRef])
return (
<HStack
className={css({
margin: '0.75rem 0 1.5rem',
padding: '0.5rem',
backgroundColor: 'gray.100',
borderRadius: 4,
})}
>
<TextArea
ref={inputRef}
onKeyDown={(e) => {
e.stopPropagation()
submitOnEnter(e)
}}
onKeyUp={(e) => e.stopPropagation()}
placeholder={t('textArea.placeholder')}
value={text}
onChange={(e) => {
setText(e.target.value)
}}
rows={rows || 1}
style={{
border: 'none',
resize: 'none',
height: 'auto',
minHeight: `34px`,
lineHeight: 1.25,
padding: '7px 10px',
overflowY: 'hidden',
}}
placeholderStyle={'strong'}
spellCheck={false}
maxLength={2000}
aria-label={t('textArea.label')}
/>
<Button
square
invisible
variant="tertiaryText"
size="sm"
onPress={handleSubmit}
isDisabled={isDisabled}
aria-label={t('button.label')}
>
<RiSendPlane2Fill />
</Button>
</HStack>
)
}
@@ -2,7 +2,7 @@ import { Button } from '@/primitives'
import { RiSettings3Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { openSettingsDialog } from '@/stores/settings'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
export const SettingsButton = ({
settingTab,
@@ -12,6 +12,7 @@ export const SettingsButton = ({
onPress?: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const { openSettingsDialog } = useSettingsDialog()
return (
<Button
@@ -9,8 +9,8 @@ import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import {
useIsSpeaking,
useLocalParticipant,
useMaybeRoomContext,
useRoomContext,
} from '@livekit/components-react'
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
@@ -169,7 +169,7 @@ export const ToggleDevice = <T extends ToggleSource>({
}
const ActiveSpeakerWrapper = () => {
const room = useRoomContext()
const isSpeaking = useIsSpeaking(room.localParticipant)
const { localParticipant } = useLocalParticipant()
const isSpeaking = useIsSpeaking(localParticipant)
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
}
@@ -1,23 +0,0 @@
import { RiBookOpenLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { useConfig } from '@/api/useConfig'
export const DocumentationMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { data } = useConfig()
if (!data?.documentation_url) return
return (
<MenuItem
href={data.documentation_url}
target="_blank"
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
<RiBookOpenLine size={20} />
{t('documentation')}
</MenuItem>
)
}
@@ -5,7 +5,6 @@ import { SettingsMenuItem } from './SettingsMenuItem'
import { FeedbackMenuItem } from './FeedbackMenuItem'
import { EffectsMenuItem } from './EffectsMenuItem'
import { SupportMenuItem } from './SupportMenuItem'
import { DocumentationMenuItem } from './DocumentationMenuItem'
import { TranscriptMenuItem } from './TranscriptMenuItem'
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
@@ -29,7 +28,6 @@ export const OptionsMenuItems = () => {
<Separator />
<MenuSection>
<SupportMenuItem />
<DocumentationMenuItem />
<FeedbackMenuItem />
<SettingsMenuItem />
</MenuSection>
@@ -2,10 +2,11 @@ import { RiSettings3Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { openSettingsDialog } from '@/stores/settings'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
export const SettingsMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { openSettingsDialog } = useSettingsDialog()
return (
<MenuItem
@@ -4,47 +4,27 @@ import { HStack } from '@/styled-system/jsx'
import { Text } from '@/primitives/Text'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/components/Avatar'
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
import { useLowerHandParticipant } from '@/features/rooms/api/lowerHandParticipant'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
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'
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) => {
export const HandRaisedListItem = ({
participant,
}: HandRaisedListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
const { lowerHandParticipant } = useLowerHandParticipant()
const isAdminOrOwner = useIsAdminOrOwner()
return (
<HStack
role="listitem"
@@ -89,9 +69,19 @@ export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
)}
</Text>
</HStack>
<AdminOrOwnerOnly>
<ActionButton participant={participant} name={name} />
</AdminOrOwnerOnly>
{isAdminOrOwner && (
<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>
)}
</HStack>
)
}
@@ -1,22 +1,23 @@
import { Button } from '@/primitives'
import { useTranslation } from 'react-i18next'
import type { Participant } from 'livekit-client'
import { useLowerHandParticipants } from '../api/lowerHandParticipants'
import { useLowerHandParticipants } from '@/features/rooms/api/lowerHandParticipants'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { css } from '@/styled-system/css'
import { RiHand } from '@remixicon/react'
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
type LowerAllHandsButtonProps = {
participants: Array<Participant>
}
const LowerAllHandsButtonInner = ({
export const LowerAllHandsButton = ({
participants,
}: LowerAllHandsButtonProps) => {
const { lowerHandParticipants } = useLowerHandParticipants()
const { t } = useTranslation('rooms')
if (!participants.length) return null
const isAdminOrOwner = useIsAdminOrOwner()
if (!isAdminOrOwner) return null
return (
<Button
@@ -35,13 +36,3 @@ const LowerAllHandsButtonInner = ({
</Button>
)
}
export const LowerAllHandsButton = ({
participants,
}: LowerAllHandsButtonProps) => {
return (
<AdminOrOwnerOnly>
<LowerAllHandsButtonInner participants={participants} />
</AdminOrOwnerOnly>
)
}
@@ -16,11 +16,11 @@ import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button } from '@/primitives'
import { useState } from 'react'
import { MuteAlertDialog } from '../../MuteAlertDialog'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
import { PinBadge } from './PinBadge'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
type MicIndicatorProps = {
participant: Participant
@@ -93,7 +93,9 @@ type ParticipantListItemProps = {
participant: Participant
}
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
export const ParticipantListItem = ({
participant,
}: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
return (
@@ -0,0 +1,110 @@
import { ReactNode, useState } from 'react'
import { css } from '@/styled-system/css'
import { ToggleButton } from 'react-aria-components'
import { HStack, styled, VStack } from '@/styled-system/jsx'
import { RiArrowUpSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
const ToggleHeader = styled(ToggleButton, {
base: {
minHeight: '40px', //fixme hardcoded value
paddingRight: '.5rem',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
width: '100%',
alignItems: 'center',
transition: 'background 200ms',
borderTopRadius: '7px',
'&[data-hovered]': {
backgroundColor: 'greyscale.50',
},
},
})
const Container = styled('div', {
base: {
border: '1px solid',
borderColor: 'greyscale.250',
borderRadius: '8px',
margin: '0 .625rem',
},
})
const ListContainer = styled(VStack, {
base: {
borderTop: '1px solid',
borderTopColor: 'greyscale.250',
alignItems: 'start',
overflowY: 'scroll',
overflowX: 'hidden',
minHeight: 0,
flexGrow: 1,
display: 'flex',
paddingY: '0.5rem',
paddingX: '1rem',
gap: 0,
},
})
export type ParticipantsCollapsableListProps<T> = {
heading: string
participants: Array<T>
renderParticipant: (participant: T) => JSX.Element
action?: ReactNode
}
export function ParticipantsCollapsableList<T>({
heading,
participants,
renderParticipant,
action,
}: ParticipantsCollapsableListProps<T>) {
const { t } = useTranslation('rooms')
const [isOpen, setIsOpen] = useState(true)
const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, {
name: heading,
})
return (
<Container>
<ToggleHeader
isSelected={isOpen}
aria-label={label}
onPress={() => setIsOpen(!isOpen)}
style={{
borderRadius: !isOpen ? '7px' : undefined,
}}
>
<HStack
justify="space-between"
className={css({
margin: '0 1.25rem',
width: '100%',
})}
>
<div
className={css({
fontSize: '1rem',
})}
>
{heading}
</div>
<div>{participants?.length || 0}</div>
</HStack>
<RiArrowUpSLine
size={32}
style={{
transform: isOpen ? 'rotate(-180deg)' : undefined,
transition: 'transform 200ms',
}}
/>
</ToggleHeader>
{isOpen && (
<ListContainer>
{action}
{participants.map((participant) => renderParticipant(participant))}
</ListContainer>
)}
</Container>
)
}
@@ -0,0 +1,112 @@
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { Div, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem'
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
import { WaitingParticipantListItem } from './WaitingParticipantListItem'
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
import type { Participant } from 'livekit-client'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { MuteEveryoneButton } from './MuteEveryoneButton'
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
export const ParticipantsList = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
// because the 'useLocalParticipant' hook does not update the participant's information when their
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
const participants = useParticipants()
const sortedRemoteParticipants = participants
.slice(1)
.sort((participantA, participantB) => {
const nameA = participantA.name || participantA.identity
const nameB = participantB.name || participantB.identity
return nameA.localeCompare(nameB)
})
const sortedParticipants = [
participants[0], // first participant returned by the hook, is always the local one
...sortedRemoteParticipants,
]
const raisedHandParticipants = participants
.filter((participant) => !!participant.attributes.handRaisedAt)
.sort((a, b) => {
const dateA = new Date(a.attributes.handRaisedAt)
const dateB = new Date(b.attributes.handRaisedAt)
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
return timeA - timeB
})
const { waitingParticipants, handleParticipantEntry } =
useWaitingParticipants()
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
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>
{waitingParticipants?.length > 0 && (
<Div marginBottom=".9375rem">
<ParticipantsCollapsableList<WaitingParticipant>
heading={t('waiting.title')}
participants={waitingParticipants}
renderParticipant={(participant) => (
<WaitingParticipantListItem
key={participant.id}
participant={participant}
onAction={handleParticipantEntry}
/>
)}
action={<></>}
/>
</Div>
)}
{raisedHandParticipants.length > 0 && (
<Div marginBottom=".9375rem">
<ParticipantsCollapsableList<Participant>
heading={t('raisedHands')}
participants={raisedHandParticipants}
renderParticipant={(participant) => (
<HandRaisedListItem
key={participant.identity}
participant={participant}
/>
)}
action={
<LowerAllHandsButton participants={raisedHandParticipants} />
}
/>
</Div>
)}
<ParticipantsCollapsableList<Participant>
heading={t('contributors')}
participants={sortedParticipants}
renderParticipant={(participant) => (
<ParticipantListItem
key={participant.identity}
participant={participant}
/>
)}
action={<MuteEveryoneButton participants={sortedRemoteParticipants} />}
/>
</Div>
)
}
@@ -0,0 +1,90 @@
import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ParticipantsToggle = ({
onPress,
...props
}: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
/**
* Context could not be used due to inconsistent refresh behavior.
* The 'numParticipant' property on the room only updates when the room's metadata changes,
* resulting in a delay compared to the participant list's actual refresh rate.
*/
const participants = useParticipants()
const numParticipants = participants?.length
const announcedCount =
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'toggle-participants',
handler: toggleParticipants,
})
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
<ToggleButton
square
variant="primaryTextDark"
aria-label={`${t(tooltipLabel)}. ${t('count', {
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
aria-expanded={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
</VisualOnlyTooltip>
<div
className={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',
})}
aria-hidden={true}
>
{numParticipants < 100 ? (
numParticipants || 1
) : (
<RiInfinityLine size={10} />
)}
</div>
</div>
)
}
@@ -0,0 +1,29 @@
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
import type { Participant } from 'livekit-client'
import { RiPushpin2Fill } from '@remixicon/react'
import { css } from '@/styled-system/css'
export const PinBadge = ({ participant }: { participant: Participant }) => {
const { inFocus } = useFocusToggleParticipant(participant)
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>
)
}
@@ -3,10 +3,10 @@ import { HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Avatar } from '@/components/Avatar'
import { useTranslation } from 'react-i18next'
import { WaitingParticipant } from '../api/listWaitingParticipants'
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
import { RiCloseLine } from '@remixicon/react'
export const WaitingParticipantRow = ({
export const WaitingParticipantListItem = ({
participant,
onAction,
}: {
@@ -1,56 +0,0 @@
import { useCallback, useId } from 'react'
import { useTranslation } from 'react-i18next'
import { RiGroupLine } from '@remixicon/react'
import { ToggleButton, type ToggleButtonProps } from '@/primitives/ToggleButton'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { css } from '@/styled-system/css'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { ParticipantsCount } from '@/features/participants/components/ParticipantsCount'
const containerStyles = css({ position: 'relative', display: 'inline-block' })
export const ParticipantsToggle = ({
onPress,
...props
}: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const countId = useId()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'toggle-participants',
handler: toggleParticipants,
})
const handlePress = useCallback(
(e: Parameters<NonNullable<ToggleButtonProps['onPress']>>[0]) => {
toggleParticipants()
onPress?.(e)
},
[toggleParticipants, onPress]
)
return (
<div className={containerStyles}>
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
<ToggleButton
square
variant="primaryTextDark"
aria-label={t(tooltipLabel)}
aria-describedby={countId}
isSelected={isParticipantsOpen}
aria-expanded={isParticipantsOpen}
onPress={handlePress}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
</VisualOnlyTooltip>
<ParticipantsCount describedById={countId} />
</div>
)
}
@@ -1,8 +1,15 @@
import type { TrackSource } from '@livekit/protocol'
import { useLocalParticipantPermissions } from '@livekit/components-react'
import {
useLocalParticipant,
useParticipantPermissions,
} from '@livekit/components-react'
export function useCanPublishTrack(trackSource: TrackSource): boolean {
const permissions = useLocalParticipantPermissions()
const { localParticipant } = useLocalParticipant()
const permissions = useParticipantPermissions({
participant: localParticipant,
})
return Boolean(
permissions?.canPublish &&
permissions?.canPublishSources?.includes(trackSource)
@@ -1,35 +1,27 @@
import { useConfig } from '@/api/useConfig'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import {
useRemoteParticipants,
useRoomContext,
} from '@livekit/components-react'
import { useEffect, useRef } from 'react'
import { useSnapshot } from 'valtio'
import { DisconnectReason, RoomEvent } from 'livekit-client'
import { userPreferencesStore } from '@/stores/userPreferences'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import posthog from 'posthog-js'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { isMobileBrowser } from '@livekit/components-core'
import { FeatureFlags } from '@/features/analytics/enums'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { useConfig } from '@/api/useConfig'
import { userPreferencesStore } from '@/stores/userPreferences'
import { useSnapshot } from 'valtio'
import { useIsAdvancedConnectionObserverEnabled } from './useIsAdvancedConnectionObserverEnabled'
const CANDIDATE_POLL_INTERVAL_MS = 5000
export const ConnectionObserver = () => {
export const useConnectionObserver = () => {
const room = useRoomContext()
const connectionStartTimeRef = useRef<number | null>(null)
const { data } = useConfig()
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
const isMobile = isMobileBrowser()
const isAdvancedConnectionObserverEnabled =
!isMobile && isAnalyticsEnabled && featureEnabled
useIsAdvancedConnectionObserverEnabled()
const userPreferencesSnap = useSnapshot(userPreferencesStore)
@@ -240,6 +232,4 @@ export const ConnectionObserver = () => {
connectionStartTimeRef.current = null
}
}, [])
return null
}
@@ -0,0 +1,12 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { FeatureFlags } from '@/features/analytics/enums.ts'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled.ts'
import { isMobileBrowser } from '@livekit/components-core'
export const useIsAdvancedConnectionObserverEnabled = () => {
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isMobile = isMobileBrowser()
return !isMobile && isAnalyticsEnabled && featureEnabled
}

Some files were not shown because too many files have changed in this diff Show More