Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 3384f0f3c7 🔖(minor) bump release to 1.28.0 2026-08-24 11:31:19 +02:00
48 changed files with 303 additions and 1052 deletions
-1
View File
@@ -75,7 +75,6 @@ db.sqlite3
# IDEs # IDEs
.idea/ .idea/
.vscode/ .vscode/
.cursor/
*.iml *.iml
.devcontainer .devcontainer
-24
View File
@@ -8,30 +8,6 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- ✨(frontend) share OneToOneFocusLayout between PiP and main room
## [1.29.0] - 2026-08-25
### Added
- ✨(any) let any authenticated user manage the lobby on trusted rooms
### Changed
- 📱(frontend) collapse mobile control bar items on narrow viewports
- 📱(frontend) stack idle modal buttons in a column on mobile
- 📱(frontend) improve feedback screen responsiveness on mobile
- ⬆️(frontend) upgrade @fontsource-variable/atkinson-hyperlegible-next
- ⬆️(frontend) upgrade i18next-resources-to-backend from 1.2.1 to 1.2.3
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
- ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
- ⚡️(frontend) apply frugal constraint to the active meeting audio track
- ⚡️(backend) replace blocking Redis KEYS with cursor-based SCAN
- ✨(summary) add hostname to analytics properties
## [1.28.0] - 2026-08-24 ## [1.28.0] - 2026-08-24
### Added ### Added
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.29.0" version = "1.28.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.6.7", "livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]] [[package]]
name = "agents" name = "agents"
version = "1.29.0" version = "1.28.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "livekit-agents" }, { name = "livekit-agents" },
+1 -46
View File
@@ -5,7 +5,7 @@ from django.http import Http404
from rest_framework import permissions from rest_framework import permissions
from ..models import RoleChoices, RoomAccessLevel from ..models import RoleChoices
from ..services.participants_management import ( from ..services.participants_management import (
ParticipantNotFoundException, ParticipantNotFoundException,
ParticipantsManagement, ParticipantsManagement,
@@ -198,48 +198,3 @@ class IsPresentInMeeting(permissions.BasePermission):
return False return False
except ParticipantsManagementException: except ParticipantsManagementException:
return False return False
class CanManageLobby(permissions.BasePermission):
"""Grant lobby management (list/accept/deny waiting participants).
- Room admins/owners can always manage the lobby.
- When the room access level is TRUSTED, any authenticated user who is
currently connected to the meeting can manage the lobby. Presence is
verified cache-first (Redis), falling back to the LiveKit API.
Access level is always read fresh from the DB; only presence is cached,
so changing the room to RESTRICTED takes effect immediately.
"""
message = "You are not allowed to manage this room's lobby."
# pylint: disable=too-many-return-statements
def has_object_permission(self, request, view, obj): # noqa: PLR0911
"""Check privileges first, then the trusted-room presence path."""
user = request.user
if not user or not user.is_authenticated:
return False
# Product choice: lobby management is reserved for session-authenticated
# users with a real account, not holders of a LiveKit room token.
if request.auth and hasattr(request.auth, "video"):
return False
if obj.is_administrator_or_owner(user):
return True
if obj.access_level != RoomAccessLevel.TRUSTED:
return False
self.message = "You must be connected to the meeting to manage its lobby."
try:
return ParticipantsManagement().check_if_in_meeting_cached(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
+2 -2
View File
@@ -536,7 +536,7 @@ class RoomViewSet(
methods=["post"], methods=["post"],
url_path="enter", url_path="enter",
permission_classes=[ permission_classes=[
permissions.CanManageLobby, permissions.HasPrivilegesOnRoom,
], ],
) )
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
@@ -574,7 +574,7 @@ class RoomViewSet(
methods=["GET"], methods=["GET"],
url_path="waiting-participants", url_path="waiting-participants",
permission_classes=[ permission_classes=[
permissions.CanManageLobby, permissions.HasPrivilegesOnRoom,
], ],
) )
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
@@ -23,7 +23,6 @@ from core.recording.services.recording_events import (
) )
from .lobby import LobbyService from .lobby import LobbyService
from .presence import PresenceCache
from .room_management import ( from .room_management import (
RoomManagement, RoomManagement,
RoomManagementException, RoomManagementException,
@@ -100,7 +99,6 @@ class LiveKitEventsService:
"egress_ended": self._handle_egress_ended, "egress_ended": self._handle_egress_ended,
"room_started": self._handle_room_started, "room_started": self._handle_room_started,
"room_finished": self._handle_room_finished, "room_finished": self._handle_room_finished,
"participant_left": self._handle_participant_left,
} }
token_verifier = api.TokenVerifier( token_verifier = api.TokenVerifier(
@@ -109,7 +107,6 @@ class LiveKitEventsService:
) )
self.webhook_receiver = api.WebhookReceiver(token_verifier) self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService() self.lobby_service = LobbyService()
self.presence_cache = PresenceCache()
self.sip_management = SIPManagement() self.sip_management = SIPManagement()
self.recording_events = RecordingEventsService() self.recording_events = RecordingEventsService()
@@ -288,31 +285,9 @@ class LiveKitEventsService:
f"Failed to delete sip dispatch rule for room {room_id}" f"Failed to delete sip dispatch rule for room {room_id}"
) from e ) from e
self.presence_cache.clear_room(room_id)
try: try:
self.lobby_service.clear_room_cache(room_id) self.lobby_service.clear_room_cache(room_id)
except Exception as e: except Exception as e:
raise ActionFailedError( raise ActionFailedError(
f"Failed to clear room cache for room {room_id}" f"Failed to clear room cache for room {room_id}"
) from e ) from e
def _handle_participant_left(self, data):
"""Handle 'participant_left': invalidate the presence cache.
Presence entries are created lazily (only for users who administrate
the lobby of a trusted room), so for most participants this delete is
a no-op DEL on a key that never existed. Eager invalidation shrinks
the window during which a departed participant could still act on a
trusted room's lobby (cache hit until TTL expiry). It is gated behind
`PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be
measured and the behaviour reverted independently of the feature.
When disabled, invalidation relies on `room_finished` and the TTL.
"""
if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT:
return
identity = data.participant.identity
if not identity:
return
self.presence_cache.clear(data.room.name, identity)
+8 -4
View File
@@ -270,7 +270,7 @@ class LobbyService:
"""List all waiting participants for a room.""" """List all waiting participants for a room."""
pattern = self._get_cache_key(room_id, "*") pattern = self._get_cache_key(room_id, "*")
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE)) keys = cache.keys(pattern)
if not keys: if not keys:
return [] return []
@@ -345,9 +345,13 @@ class LobbyService:
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room.""" """Clear all participant entries from the cache for a specific room."""
cache.delete_pattern( pattern = self._get_cache_key(room_id, "*")
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE keys = cache.keys(pattern)
)
if not keys:
return
cache.delete_many(keys)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None: def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room.""" """Clear a given participant entry from the cache for a specific room."""
@@ -20,7 +20,6 @@ from livekit.protocol.models import ParticipantInfo
from core import utils from core import utils
from .lobby import LobbyService from .lobby import LobbyService
from .presence import PresenceCache
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -73,9 +72,7 @@ class ParticipantsManagement:
@async_to_sync @async_to_sync
async def remove(self, room_name: str, identity: str): async def remove(self, room_name: str, identity: str):
"""Remove a participant from a room and clear their lobby/presence cache.""" """Remove a participant from a room and clear their lobby cache."""
PresenceCache().clear(room_name, identity)
try: try:
LobbyService().clear_participant_cache( LobbyService().clear_participant_cache(
@@ -159,30 +156,6 @@ class ParticipantsManagement:
finally: finally:
await lkapi.aclose() await lkapi.aclose()
def check_if_in_meeting_cached(self, room_name: str, identity: str) -> bool:
"""Cache-first variant of `check_if_in_meeting`.
Cache hit -> True without touching LiveKit.
Cache miss -> ask LiveKit; memoize only positive answers.
Raises the same exceptions as `check_if_in_meeting` so callers keep
failing closed the same way.
"""
if not room_name or not identity:
return False
presence_cache = PresenceCache()
if presence_cache.is_marked_present(room_name, identity):
return True
present = self.check_if_in_meeting(room_name=room_name, identity=identity)
if present:
presence_cache.mark_present(room_name, identity)
return present
@async_to_sync @async_to_sync
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool: async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`. """Check whether `identity` is currently a participant in `room_name`.
-52
View File
@@ -1,52 +0,0 @@
"""Presence cache.
Redis-backed memo of "this identity is currently connected to this room".
This module is intentionally a *pure cache store* with no dependency on other
services, so that `participants_management` (which talks to LiveKit) can
import it without creating an import cycle. The composition of "check cache,
fall back to LiveKit" lives in
`ParticipantsManagement.check_if_in_meeting_cached`.
Only positive answers are stored: a sticky negative would lock out someone
who joins right after a miss for the whole TTL. The TTL is a safety net in
case an invalidation webhook is lost.
"""
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from core.utils import CACHE_SCAN_ITERSIZE
class PresenceCache:
"""Store and invalidate (room, identity) presence entries."""
@staticmethod
def _get_cache_key(room_id: UUID | str, identity: str) -> str:
"""Cache key for a (room, identity) presence entry."""
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
"""Return True if a positive presence entry exists in cache."""
return bool(cache.get(self._get_cache_key(room_id, identity)))
def mark_present(self, room_id: UUID | str, identity: str) -> None:
"""Record that `identity` is in `room_id`."""
cache.set(
self._get_cache_key(room_id, identity),
True,
timeout=settings.PRESENCE_CACHE_TIMEOUT,
)
def clear(self, room_id: UUID | str, identity: str) -> None:
"""Forget presence for one participant (e.g. on participant_left)."""
cache.delete(self._get_cache_key(room_id, identity))
def clear_room(self, room_id: UUID | str) -> None:
"""Forget presence for every participant of a room (on room_finished)."""
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
)
@@ -389,7 +389,7 @@ def test_allow_participant_to_enter_anonymous():
def test_allow_participant_to_enter_non_owner(): def test_allow_participant_to_enter_non_owner():
"""Non-privileged users should not be allowed to manage entry requests.""" """Non-privileged users should not be allowed to manage entry requests."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory()
user = UserFactory() user = UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -522,7 +522,7 @@ def test_list_waiting_participants_anonymous():
def test_list_waiting_participants_non_owner(): def test_list_waiting_participants_non_owner():
"""Non-privileged users should not be allowed to list waiting participants.""" """Non-privileged users should not be allowed to list waiting participants."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory()
user = UserFactory() user = UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -1,106 +0,0 @@
"""Trusted rooms: any authenticated participant present in the meeting can manage the lobby."""
from unittest import mock
import pytest
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory
from core.models import RoomAccessLevel
from core.services.presence import PresenceCache
pytestmark = pytest.mark.django_db
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_present_user_can_list_waiting(mock_check):
"""Authenticated + present in a trusted room -> 200, LiveKit asked once then cached."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
assert client.get(url).status_code == 200
assert client.get(url).status_code == 200
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_absent_user_forbidden(mock_check):
"""Authenticated but not connected to the meeting -> 403."""
mock_check.return_value = False
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_restricted_room_present_user_forbidden(mock_check):
"""Presence is not enough on a restricted room; LiveKit must not even be asked."""
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_presence_cleared_after_leave(mock_check):
"""Once the presence cache is cleared (participant_left), LiveKit is re-checked."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
assert client.get(url).status_code == 200
PresenceCache().clear(room.id, str(user.sub))
mock_check.return_value = False
assert client.get(url).status_code == 403
assert mock_check.call_count == 2
def test_trusted_room_anonymous_forbidden():
"""Anonymous users never manage the lobby."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
response = APIClient().get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 401
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_trusted_room_present_user_can_accept_entry(mock_check):
"""Authenticated + present in a trusted room can accept a waiting participant."""
mock_check.return_value = True
user = UserFactory()
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
# Permission passed; 404 because that participant isn't actually waiting.
assert response.status_code == 404
assert response.json() == {"message": "Participant not found."}
@@ -854,31 +854,3 @@ def test_receive_ignores_connection_test_room(
mock_handle_room_started.assert_not_called() mock_handle_room_started.assert_not_called()
mock_handle_room_finished.assert_not_called() mock_handle_room_finished.assert_not_called()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_clearing_gated_by_setting(mock_delete, service, settings):
"""PRESENCE_CLEAR_ON_PARTICIPANT_LEFT toggles eager presence invalidation."""
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = "user-sub"
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = False
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_called_once()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_without_identity_is_ignored(mock_delete, service, settings):
"""No cache operation when the webhook carries no identity."""
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = ""
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
+10 -11
View File
@@ -24,7 +24,6 @@ from core.services.lobby import (
LobbyParticipantStatus, LobbyParticipantStatus,
LobbyService, LobbyService,
) )
from core.services.presence import CACHE_SCAN_ITERSIZE
from core.utils import NotificationError from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -579,14 +578,14 @@ def test_get_participant_parsing_error(
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service): def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist.""" """Test listing waiting participants when none exist."""
mock_cache.iter_keys.return_value = [] mock_cache.keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
assert result == [] assert result == []
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_not_called() mock_cache.get_many.assert_not_called()
@@ -595,7 +594,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data.""" """Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.iter_keys.return_value = [cache_key] mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: participant_dict} mock_cache.get_many.return_value = {cache_key: participant_dict}
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
@@ -604,7 +603,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
assert result[0]["status"] == "waiting" assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username" assert result[0]["username"] == "test-username"
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key]) mock_cache.get_many.assert_called_once_with([cache_key])
@@ -629,7 +628,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"color": "#654321", "color": "#654321",
} }
mock_cache.iter_keys.return_value = [cache_key1, cache_key2] mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: participant1, cache_key1: participant1,
cache_key2: participant2, cache_key2: participant2,
@@ -647,7 +646,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
assert all(p["status"] == "waiting" for p in result) assert all(p["status"] == "waiting" for p in result)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -656,7 +655,7 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data.""" """Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.iter_keys.return_value = [cache_key] mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}} mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
@@ -681,7 +680,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
corrupted_participant = {"invalid": "data"} corrupted_participant = {"invalid": "data"}
mock_cache.iter_keys.return_value = [cache_key1, cache_key2] mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: corrupted_participant, cache_key1: corrupted_participant,
cache_key2: valid_participant, cache_key2: valid_participant,
@@ -700,7 +699,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
# Verify both cache keys were queried # Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*" pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE) mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -724,7 +723,7 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"color": "#654321", "color": "#654321",
} }
mock_cache.iter_keys.return_value = [cache_key1, cache_key2] mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: participant1, cache_key1: participant1,
cache_key2: participant2, cache_key2: participant2,
@@ -1,108 +0,0 @@
"""Tests for the presence cache and the cached presence check."""
# pylint: disable=W0212
from unittest import mock
from uuid import uuid4
from django.core.cache import cache
import pytest
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.presence import PresenceCache
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_hit_skips_livekit(mock_check):
"""A cached positive answer must not call LiveKit."""
room_id, identity = str(uuid4()), "user-sub"
PresenceCache().mark_present(room_id, identity)
assert (
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) is True
)
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_miss_calls_livekit_and_caches_positive(mock_check):
"""On a miss, LiveKit is asked once and a positive answer is memoized."""
mock_check.return_value = True
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_negative_not_cached(mock_check):
"""Negative answers are never memoized."""
mock_check.return_value = False
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert mock_check.call_count == 2
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_errors_propagate_and_cache_nothing(mock_check):
"""LiveKit errors propagate to the caller (which fails closed); nothing cached."""
room_id, identity = str(uuid4()), "user-sub"
for exc in (ParticipantNotFoundException(), ParticipantsManagementException()):
mock_check.side_effect = exc
with pytest.raises(type(exc)):
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity)
assert cache.get(PresenceCache._get_cache_key(room_id, identity)) is None
def test_presence_clear_and_clear_room():
"""clear() removes one entry, clear_room() removes all entries of a room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
presence.mark_present(room_id, "a")
presence.mark_present(room_id, "b")
presence.mark_present(other_room, "a")
presence.clear(room_id, "a")
assert presence.is_marked_present(room_id, "a") is False
assert presence.is_marked_present(room_id, "b") is True
presence.clear_room(room_id)
assert presence.is_marked_present(room_id, "b") is False
assert presence.is_marked_present(other_room, "a") is True
def test_presence_clear_room_scans_in_pages():
"""clear_room removes every match, even across several SCAN pages,
and only within the room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
for i in range(7):
presence.mark_present(room_id, f"user-{i}")
presence.mark_present(other_room, "user-0")
# An itersize smaller than the match count forces delete_pattern to
# page through several SCAN cursors rather than finish in one pass.
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
presence.clear_room(room_id)
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
assert presence.is_marked_present(other_room, "user-0") is True
-3
View File
@@ -512,6 +512,3 @@ def build_telephony_config():
"default_country": country, "default_country": country,
"international_phone_number": international, "international_phone_number": international,
} }
CACHE_SCAN_ITERSIZE = 500
-9
View File
@@ -856,15 +856,6 @@ class Base(Configuration):
) )
# Lobby configurations # Lobby configurations
PRESENCE_KEY_PREFIX = values.Value(
"room_presence", environ_name="PRESENCE_KEY_PREFIX", environ_prefix=None
)
PRESENCE_CACHE_TIMEOUT = values.PositiveIntegerValue(
3600, environ_name="PRESENCE_CACHE_TIMEOUT", environ_prefix=None
)
PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = values.BooleanValue(
True, environ_name="PRESENCE_CLEAR_ON_PARTICIPANT_LEFT", environ_prefix=None
)
LOBBY_KEY_PREFIX = values.Value( LOBBY_KEY_PREFIX = values.Value(
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None "room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
) )
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.29.0" version = "1.28.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.29.0" version = "1.28.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
+32 -44
View File
@@ -1,14 +1,14 @@
{ {
"name": "meet", "name": "meet",
"version": "1.29.0", "version": "1.28.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.29.0", "version": "1.28.0",
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0", "@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11", "@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5", "@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4", "@libreaudio/la-call": "0.1.4",
@@ -16,10 +16,10 @@
"@livekit/components-styles": "1.2.0", "@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2", "@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14", "@mediapipe/tasks-vision": "0.10.14",
"@pandacss/preset-panda": "1.12.0", "@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0", "@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3", "hoofd": "1.7.3",
@@ -27,9 +27,9 @@
"i18next": "26.3.6", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3", "i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.21.0", "livekit-client": "2.21.0",
"posthog-js": "1.409.5", "posthog-js": "1.404.1",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
@@ -767,9 +767,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": { "node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
"version": "5.3.0", "version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.3.0.tgz", "resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.2.6.tgz",
"integrity": "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==", "integrity": "sha512-D0Z8peFkRGYACvmWp8Sl8YPGYLBEJbzqapugtsMEYSUUtpQDiZVDHjk9s7bqB4hSCRYCrKuW6V1V+mnHybVnPw==",
"license": "OFL-1.1", "license": "OFL-1.1",
"funding": { "funding": {
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
@@ -1642,21 +1642,21 @@
} }
}, },
"node_modules/@pandacss/preset-panda": { "node_modules/@pandacss/preset-panda": {
"version": "1.12.0", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.12.0.tgz", "resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.3.tgz",
"integrity": "sha512-jhzhAmtGY34b1FlMJGEAh/IkfU4WyTNT2mpJfcBUPBi1rZLHSTcs0aqedRmMdwM4tTT43XFOXMiazbztKME60A==", "integrity": "sha512-IHeRQyIYeGKpQl78A/oI9fGg8MHQiOX/HttH+iQNaRU/MbGN6JsPe/N0ekQC6QY8iiKtLhqR+SRc455SN6JVWw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@pandacss/types": "1.12.0" "@pandacss/types": "1.11.3"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": { "node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": {
"version": "1.12.0", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.12.0.tgz", "resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.11.3.tgz",
"integrity": "sha512-PtlEIOBEV3RzWffeu1zH+/YFmP+fn24M63STGEbgTwjEBMhyo/FfT2sfZGB8oWAbWUBKBp69OMg25E+/rY3klQ==", "integrity": "sha512-tUbxa3WzoCuulP6kcMn7f0uNgLBvu81JAg4sfoUpz6PZE36VHA3k9n6ROEwziTbr7vqgeCj5DYQCUldrVNbEGQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -1719,16 +1719,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@posthog/browser-common": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.3.1.tgz",
"integrity": "sha512-1nhMVY1wnHADTg8tR9yvm+lPAz5ROxznfQlBtLzB2FFo4lp/LU8lk9KyFPsARDkBxCfYachsJfvRjocL1G/AJQ==",
"license": "MIT",
"dependencies": {
"@posthog/core": "^1.46.0",
"@posthog/types": "^1.399.0"
}
},
"node_modules/@posthog/core": { "node_modules/@posthog/core": {
"version": "1.48.3", "version": "1.48.3",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
@@ -2424,9 +2414,9 @@
} }
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.101.4", "version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", "integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -2445,12 +2435,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.101.4", "version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", "integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.101.4" "@tanstack/query-core": "5.101.1"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -6985,10 +6975,9 @@
} }
}, },
"node_modules/i18next-resources-to-backend": { "node_modules/i18next-resources-to-backend": {
"version": "1.2.3", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.3.tgz", "resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz",
"integrity": "sha512-8Y/LLAm5fqZc2ckQxtTWbu75ndjNLzF7mcYl6rhc4g+WBopIsG3YhdYqF5qhJy64tlYhlU2KSSnEgW3gGHLqwg==", "integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.23.2" "@babel/runtime": "^7.23.2"
} }
@@ -9134,14 +9123,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/posthog-js": { "node_modules/posthog-js": {
"version": "1.409.5", "version": "1.404.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.409.5.tgz", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
"integrity": "sha512-s1iJz+vq0YAluUD4OwLjUFIJt/9pqiiB6MITvHWIS16a7pqTJqbSLXsd5/8kJcIgfrOSZHe+mdYAXLYcJCS4LQ==", "integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
"license": "(Apache-2.0 AND MIT)", "license": "(Apache-2.0 AND MIT)",
"dependencies": { "dependencies": {
"@posthog/browser-common": "^0.3.1", "@posthog/core": "^1.43.1",
"@posthog/core": "^1.46.1", "@posthog/types": "^1.397.0",
"@posthog/types": "^1.399.0",
"core-js": "^3.49.0", "core-js": "^3.49.0",
"dompurify": "^3.3.2", "dompurify": "^3.3.2",
"fflate": "^0.4.8", "fflate": "^0.4.8",
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.29.0", "version": "1.28.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -15,7 +15,7 @@
"check": "prettier --check ./src" "check": "prettier --check ./src"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0", "@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11", "@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5", "@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4", "@libreaudio/la-call": "0.1.4",
@@ -23,10 +23,10 @@
"@livekit/components-styles": "1.2.0", "@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2", "@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14", "@mediapipe/tasks-vision": "0.10.14",
"@pandacss/preset-panda": "1.12.0", "@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0", "@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.4", "@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3", "hoofd": "1.7.3",
@@ -34,9 +34,9 @@
"i18next": "26.3.6", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3", "i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.21.0", "livekit-client": "2.21.0",
"posthog-js": "1.409.5", "posthog-js": "1.404.1",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
@@ -13,7 +13,7 @@ const controlBarRegion = cva({
mobile: { mobile: {
true: { true: {
justifyContent: 'center', justifyContent: 'center',
width: '100%', width: '330px',
}, },
}, },
}, },
@@ -1,119 +0,0 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '@/features/layout/utils/trackSelection'
type OneToOneFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
disableTileControls?: boolean
/** Controls thumbnail dimensions 'pip' for small PiP window, 'room' for the main viewport. */
context?: 'pip' | 'room'
}
/**
* Focus layout for 1-to-1 calls: one main tile filling the area (letterboxed)
* with an optional thumbnail overlay at the bottom-right.
*
* Shared between PiP and the main room pass `disableTileControls` in PiP
* where hover controls should be hidden.
*/
export const OneToOneFocusLayout = memo(
({
mainTrack,
thumbnailTrack,
disableTileControls,
context = 'room',
}: OneToOneFocusLayoutProps) => {
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableTileControls={disableTileControls}
/>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail context={context}>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls={disableTileControls}
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
OneToOneFocusLayout.displayName = 'OneToOneFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled(
'div',
cva({
base: {
position: 'absolute',
right: '1.25rem',
bottom: '1.25rem',
aspectRatio: '16 / 9',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
variants: {
context: {
pip: {
width: '42%',
maxWidth: '220px',
minWidth: '140px',
},
room: {
width: '20%',
maxWidth: '320px',
minWidth: '180px',
},
},
},
defaultVariants: {
context: 'room',
},
})
)
@@ -12,8 +12,7 @@ import {
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout' import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useRef } from 'react'
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
export const StageLayout = () => { export const StageLayout = () => {
const lastAutoFocusedScreenShareTrack = const lastAutoFocusedScreenShareTrack =
@@ -37,29 +36,6 @@ export const StageLayout = () => {
(track) => !isEqualTrackRef(track, pinnedTrackRef) (track) => !isEqualTrackRef(track, pinnedTrackRef)
) )
const cameraTracks = useMemo(
() => tracks.filter((t) => t.source === Track.Source.Camera),
[tracks]
)
const isOneToOne =
!pinnedTrackRef &&
screenShareTracks.length === 0 &&
cameraTracks.length <= 2
const oneToOneMainTrack = useMemo(() => {
if (!isOneToOne) return undefined
const remote = cameraTracks.find((t) => !t.participant?.isLocal)
const local = cameraTracks.find((t) => t.participant?.isLocal)
return remote ?? local
}, [isOneToOne, cameraTracks])
const oneToOneThumbnailTrack = useMemo(() => {
if (!isOneToOne) return undefined
const local = cameraTracks.find((t) => t.participant?.isLocal)
return oneToOneMainTrack === local ? undefined : local
}, [isOneToOne, cameraTracks, oneToOneMainTrack])
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring. // Code duplicated from LiveKit; this warning will be addressed in the refactoring.
useEffect(() => { useEffect(() => {
@@ -111,12 +87,7 @@ export const StageLayout = () => {
return ( return (
<> <>
{isOneToOne ? ( {!pinnedTrackRef ? (
<OneToOneFocusLayout
mainTrack={oneToOneMainTrack}
thumbnailTrack={oneToOneThumbnailTrack}
/>
) : !pinnedTrackRef ? (
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}> <div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
<GridLayout tracks={tracks} style={{ padding: 0 }}> <GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile /> <ParticipantTile />
@@ -1,31 +1,61 @@
import { useMemo } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { RoomEvent } from 'livekit-client'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useEnterRoom } from '../api/enterRoom' import { useEnterRoom } from '../api/enterRoom'
import { import {
useListWaitingParticipants, useListWaitingParticipants,
type WaitingParticipant, type WaitingParticipant,
} from '../../participants/api/listWaitingParticipants' } from '../../participants/api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { reportError } from '@/features/analytics/telemetry' import { reportError } from '@/features/analytics/telemetry'
export const POLL_INTERVAL_MS = 1000
export const useWaitingParticipants = () => { export const useWaitingParticipants = () => {
const [listEnabled, setListEnabled] = useState(true)
const roomData = useRoomData() const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice const roomId = roomData?.id || '' // FIXME - bad practice
const canManageLobby = useCanManageLobby() const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const handleDataReceived = useCallback((payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
if (notification?.type === NotificationType.ParticipantWaiting) {
setListEnabled(true)
}
}, [])
useEffect(() => {
if (isAdminOrOwner) {
room.on(RoomEvent.DataReceived, handleDataReceived)
}
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [isAdminOrOwner, room, handleDataReceived])
const { data: waitingData, refetch: refetchWaiting } = const { data: waitingData, refetch: refetchWaiting } =
useListWaitingParticipants(roomId, { useListWaitingParticipants(roomId, {
retry: false, retry: false,
enabled: false, enabled: listEnabled && isAdminOrOwner,
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: true,
}) })
const waitingParticipants = useMemo( const waitingParticipants = useMemo(
() => (canManageLobby ? waitingData?.participants || [] : []), () => waitingData?.participants || [],
[waitingData, canManageLobby] [waitingData]
) )
useEffect(() => {
if (!waitingParticipants.length) setListEnabled(false)
}, [waitingParticipants])
const { mutateAsync: enterRoom } = useEnterRoom() const { mutateAsync: enterRoom } = useEnterRoom()
const handleParticipantEntry = async ( const handleParticipantEntry = async (
@@ -44,6 +74,8 @@ export const useWaitingParticipants = () => {
allowEntry: boolean allowEntry: boolean
): Promise<void> => { ): Promise<void> => {
try { try {
setListEnabled(false)
await Promise.all( await Promise.all(
waitingParticipants.map((participant) => waitingParticipants.map((participant) =>
enterRoom({ enterRoom({
@@ -57,6 +89,7 @@ export const useWaitingParticipants = () => {
await refetchWaiting() await refetchWaiting()
} catch (e) { } catch (e) {
reportError('generic_failure', e) reportError('generic_failure', e)
setListEnabled(true)
} }
} }
@@ -0,0 +1,86 @@
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 { getTrackKey } from '../../utils/pipTrackSelection'
type PipFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
}
/**
* Focus layout used when 1-2 tracks are visible in the PiP window.
*
* The main tile is letterboxed (object-fit: contain) so the camera is
* never stretched to a non-video aspect and leaves dark padding
* above/below when the window shape doesn't match the source.
* The thumbnail keeps the usual cover fill.
*/
export const PipFocusLayout = memo(
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
disableTileControls
/>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
disableTileControls
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
PipFocusLayout.displayName = 'PipFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
boxSizing: 'border-box',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: '8px',
overflow: 'hidden',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1.25rem',
bottom: '1.25rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
},
})
@@ -4,7 +4,7 @@ import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile' import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { usePipElementSize } from '../../hooks/usePipElementSize' import { usePipElementSize } from '../../hooks/usePipElementSize'
import { computePipGridLayout } from '../../utils/pipGrid' import { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '@/features/layout/utils/trackSelection' import { getTrackKey } from '../../utils/pipTrackSelection'
type PipGridLayoutProps = { type PipGridLayoutProps = {
tracks: TrackReferenceOrPlaceholder[] tracks: TrackReferenceOrPlaceholder[]
@@ -2,7 +2,7 @@ import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile' import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
import { getTrackKey } from '@/features/layout/utils/trackSelection' import { getTrackKey } from '../../utils/pipTrackSelection'
type PipScreenShareLayoutProps = { type PipScreenShareLayoutProps = {
screenShareTrack: TrackReferenceOrPlaceholder screenShareTrack: TrackReferenceOrPlaceholder
@@ -2,7 +2,7 @@ import React, { useMemo } from 'react'
import { usePagination, useTracks } from '@livekit/components-react' import { usePagination, useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client' import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout' import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout' import { PipGridLayout } from './PipGridLayout'
import { PipPagination } from './PipPagination' import { PipPagination } from './PipPagination'
import { PipScreenShareLayout } from './PipScreenShareLayout' import { PipScreenShareLayout } from './PipScreenShareLayout'
@@ -59,11 +59,9 @@ export const PipStage = () => {
if (cameraTracks.length <= 1) { if (cameraTracks.length <= 1) {
return ( return (
<StageFrame> <StageFrame>
<OneToOneFocusLayout <PipFocusLayout
mainTrack={screenShareTrack} mainTrack={screenShareTrack}
thumbnailTrack={cameraTracks[0]} thumbnailTrack={cameraTracks[0]}
disableTileControls
context="pip"
/> />
</StageFrame> </StageFrame>
) )
@@ -101,12 +99,7 @@ export const PipStage = () => {
return ( return (
<StageFrame> <StageFrame>
<OneToOneFocusLayout <PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
mainTrack={mainTrack}
thumbnailTrack={thumbnailTrack}
disableTileControls
context="pip"
/>
</StageFrame> </StageFrame>
) )
} }
@@ -1,3 +1,4 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react' import { RiEmotionLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives' import { ToggleButton } from '@/primitives'
@@ -6,8 +7,6 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { REACTIONS_TOOLBAR_ID } from '../constants' import { REACTIONS_TOOLBAR_ID } from '../constants'
import { useReactionsToolbar } from '../hooks/useReactionsToolbar' import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const focusReactionsToolbar = () => { const focusReactionsToolbar = () => {
document document
@@ -18,44 +17,35 @@ const focusReactionsToolbar = () => {
export const REACTIONS_TOGGLE_ID = 'reactions-toggle' export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
/* eslint-disable react-refresh/only-export-components */ export const ReactionsToggle = () => {
export const reactionShortcutHandler = () => {
if (layoutStore.showReactionsToolbar) {
focusReactionsToolbar()
} else {
layoutStore.showReactionsToolbar = true
}
}
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const ReactionsToggle = ({
variant = 'primaryDark',
onPress,
...props
}: Props) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { isOpen, toggle } = useReactionsToolbar() const { isOpen, toggle } = useReactionsToolbar()
const handleShortcut = useCallback(() => {
if (layoutStore.showReactionsToolbar) {
focusReactionsToolbar()
} else {
layoutStore.showReactionsToolbar = true
}
}, [])
useRegisterKeyboardShortcut({ useRegisterKeyboardShortcut({
id: 'reaction', id: 'reaction',
handler: reactionShortcutHandler, handler: handleShortcut,
}) })
return ( return (
<ToggleButton <ToggleButton
{...props}
id={REACTIONS_TOGGLE_ID} id={REACTIONS_TOGGLE_ID}
data-attr="reactions-toggle" data-attr="reactions-toggle"
square square
variant={variant} variant="primaryDark"
aria-label={t('button')} aria-label={t('button')}
aria-expanded={isOpen} aria-expanded={isOpen}
tooltip={t('button')} tooltip={t('button')}
isSelected={isOpen} isSelected={isOpen}
onChange={toggle} onChange={toggle}
onPress={onPress}
> >
<RiEmotionLine /> <RiEmotionLine />
</ToggleButton> </ToggleButton>
@@ -43,7 +43,6 @@ import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences' import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user' import { userStore } from '@/stores/user'
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors' import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -116,7 +115,6 @@ export const Conference = ({
}, },
audioCaptureDefaults: { audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined, deviceId: userConfig.audioDeviceId ?? undefined,
...VOICE_AUDIO_CONSTRAINTS,
}, },
audioOutput: { audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined, deviceId: userConfig.audioOutputDeviceId ?? undefined,
@@ -1,114 +0,0 @@
import { useCallback, useEffect } from 'react'
import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useListWaitingParticipants } from '@/features/participants/api/listWaitingParticipants'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications'
import { usePrevious } from '@/hooks/usePrevious'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { ApiError } from '@/api/ApiError'
export const POLL_INTERVAL_MS = 1000
export const LAZY_POLL_INTERVAL_MS = 10_000
export const LobbyProvider = () => {
const room = useRoomContext()
const canManageLobby = useCanManageLobby()
const roomData = useRoomData()
const { isParticipantsOpen } = useSidePanel()
const isConnected = useConnectionState(room) === ConnectionState.Connected
const roomId = roomData?.id || '' // FIXME - bad practice
const { error: waitingError, refetch: refetchWaiting } =
useListWaitingParticipants(roomId, {
retry: false,
enabled: canManageLobby && isConnected && !!roomId,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: (query) => {
if (!query.state.data?.participants?.length) return false
if (isParticipantsOpen) return POLL_INTERVAL_MS
return LAZY_POLL_INTERVAL_MS
},
refetchIntervalInBackground: true,
})
// Triggers: each one-shot, idempotent, deduped by React Query if
// concurrent. The interval takes over whenever a fetch finds waiters.
const fetchIfManager = useCallback(() => {
if (canManageLobby) refetchWaiting()
}, [canManageLobby, refetchWaiting])
// 1. Connection established (join or reconnect)
useEffect(() => {
room.on(RoomEvent.Connected, fetchIfManager)
room.on(RoomEvent.Reconnected, fetchIfManager)
return () => {
room.off(RoomEvent.Connected, fetchIfManager)
room.off(RoomEvent.Reconnected, fetchIfManager)
}
}, [room, fetchIfManager])
// 2. Someone started waiting (LiveKit broadcast).
const handleDataReceived = useCallback(
(payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
if (notification?.type === NotificationType.ParticipantWaiting) {
fetchIfManager()
}
},
[fetchIfManager]
)
useEffect(() => {
if (canManageLobby) {
room.on(RoomEvent.DataReceived, handleDataReceived)
}
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [canManageLobby, room, handleDataReceived])
// 3. Rights regained.
const prevCanManageLobby = usePrevious(canManageLobby)
useEffect(() => {
if (!prevCanManageLobby && canManageLobby && isConnected) {
fetchIfManager()
}
}, [
prevCanManageLobby,
canManageLobby,
isParticipantsOpen,
fetchIfManager,
isConnected,
])
const clearWaitingList = useCallback(() => {
const queryKey = [keys.waitingParticipants, roomId]
queryClient.cancelQueries({ queryKey })
queryClient.setQueryData(queryKey, { participants: [] })
}, [roomId])
// Rights lost mid-meeting (covers trusted -> restricted/public).
useEffect(() => {
if (prevCanManageLobby && !canManageLobby) clearWaitingList()
}, [prevCanManageLobby, canManageLobby, clearWaitingList])
useEffect(() => {
if (
waitingError instanceof ApiError &&
[401, 403].includes(waitingError.statusCode)
) {
clearWaitingList()
}
}, [waitingError, clearWaitingList])
return null
}
@@ -15,8 +15,7 @@ const Card = styled('div', {
marginTop: '1.5rem', marginTop: '1.5rem',
borderRadius: '0.25rem', borderRadius: '0.25rem',
boxShadow: '', boxShadow: '',
width: '100%', minWidth: '380px',
maxWidth: '380px',
minHeight: '196px', minHeight: '196px',
}, },
}) })
@@ -38,11 +37,8 @@ const ratingButtonRecipe = cva({
color: 'initial', color: 'initial',
border: 'none', border: 'none',
borderRadius: 0, borderRadius: 0,
padding: { base: '0.5rem 0.25rem', xsm: '0.5rem 0.85rem' }, padding: '0.5rem 0.85rem',
flexGrow: '1', flexGrow: '1',
flexBasis: 0,
minWidth: 0,
textAlign: 'center',
cursor: 'pointer', cursor: 'pointer',
}, },
variants: { variants: {
@@ -103,9 +99,7 @@ const OpenFeedback = ({
return ( return (
<Card> <Card>
<H lvl={3} centered> <H lvl={3}>{t('question')}</H>
{t('question')}
</H>
<TextArea <TextArea
id="feedbackInput" id="feedbackInput"
name="feedback" name="feedback"
@@ -174,9 +168,7 @@ const RateQuality = ({
return ( return (
<Card> <Card>
<H lvl={3} centered> <H lvl={3}>{t('question')}</H>
{t('question')}
</H>
<Bar> <Bar>
{[...Array(maxRating)].map((_, index) => ( {[...Array(maxRating)].map((_, index) => (
<RACButton <RACButton
@@ -236,9 +228,7 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
}} }}
> >
<VStack gap={0}> <VStack gap={0}>
<H lvl={3} centered> <H lvl={3}>{t('heading')}</H>
{t('heading')}
</H>
<Text as="p" variant="paragraph" centered> <Text as="p" variant="paragraph" centered>
{t('body')} {t('body')}
</Text> </Text>
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver' import { connectionObserverStore } from '@/stores/connectionObserver'
import { Stack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo' import { navigateTo } from '@/navigation/navigateTo'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
@@ -134,11 +134,7 @@ export const IsIdleDisconnectModal = () => {
</H> </H>
<Description /> <Description />
<Settings /> <Settings />
<Stack <HStack marginTop="2rem">
direction={{ base: 'column', xsm: 'row' }}
align={{ base: 'stretch', xsm: 'center' }}
marginTop="2rem"
>
<Button <Button
onPress={() => { onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false connectionObserverStore.isIdleDisconnectModalOpen = false
@@ -152,7 +148,7 @@ export const IsIdleDisconnectModal = () => {
<Button onPress={close} size="sm" variant="primary"> <Button onPress={close} size="sm" variant="primary">
{t('stayButton')} {t('stayButton')}
</Button> </Button>
</Stack> </HStack>
</div> </div>
) )
}} }}
@@ -10,18 +10,10 @@ import {
showLowerHandToast, showLowerHandToast,
} from '@/features/notifications/utils' } from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const SPEAKING_DETECTION_DELAY = 3000 const SPEAKING_DETECTION_DELAY = 3000
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps export const HandToggle = () => {
export const HandToggle = ({
variant = 'primaryDark',
onPress,
...props
}: Props) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' }) const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext() const room = useRoomContext()
@@ -82,16 +74,12 @@ export const HandToggle = ({
})} })}
> >
<ToggleButton <ToggleButton
{...props}
square square
variant={variant} variant="primaryDark"
aria-label={t(tooltipLabel)} aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}
isSelected={isHandRaised} isSelected={isHandRaised}
onPress={(e) => { onPress={handleToggle}
handleToggle()
onPress?.(e)
}}
data-attr={`controls-hand-${tooltipLabel}`} data-attr={`controls-hand-${tooltipLabel}`}
> >
<RiHand /> <RiHand />
@@ -1,17 +0,0 @@
import { useUser } from '@/features/auth/api/useUser'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
import { useRoomData } from './useRoomData'
export const useCanManageLobby = () => {
const isAdminOrOwner = useIsAdminOrOwner()
const { isLoggedIn } = useUser()
const roomData = useRoomData()
return (
(isAdminOrOwner ||
(isLoggedIn === true &&
roomData?.access_level === ApiAccessLevel.TRUSTED)) &&
roomData?.access_level !== ApiAccessLevel.PUBLIC
)
}
@@ -18,7 +18,6 @@ import {
noteDeviceReady, noteDeviceReady,
onMediaPermissionError, onMediaPermissionError,
} from '../utils/mediaPermissions' } from '../utils/mediaPermissions'
import { VOICE_AUDIO_CONSTRAINTS } from '../utils/constants'
import { import {
saveAudioInputDeviceId, saveAudioInputDeviceId,
saveAudioInputEnabled, saveAudioInputEnabled,
@@ -28,6 +27,16 @@ import {
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { useSyncTrackDeviceId } from './useSyncTrackDeviceId' import { useSyncTrackDeviceId } from './useSyncTrackDeviceId'
const VOICE_AUDIO_CONSTRAINTS = {
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
voiceIsolation: false,
sampleRate: 48000,
channelCount: 1,
sampleSize: 16,
} as const
// Module-level: effect dependencies, must be referentially stable. // Module-level: effect dependencies, must be referentially stable.
const disableAudio = () => saveAudioInputEnabled(false) const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false) const disableVideo = () => saveVideoInputEnabled(false)
@@ -1,7 +1,7 @@
import { supportsScreenSharing } from '@livekit/components-core' import { supportsScreenSharing } from '@livekit/components-core'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { ControlBarAuxProps } from './ControlBar' import type { ControlBarAuxProps } from './ControlBar'
import React, { useLayoutEffect, useRef, useState } from 'react' import React from 'react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton' import { LeaveButton } from '../../components/controls/LeaveButton'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
@@ -26,26 +26,7 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl' import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { openSettingsDialog } from '@/stores/settings' import { openSettingsDialog } from '@/stores/settings'
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion' import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
import { import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
ReactionsToggle,
reactionShortcutHandler,
} from '@/features/reactions/components/ReactionsToggle'
import { useSize } from '../../hooks/useResizeObserver'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useRoomContext } from '@livekit/components-react'
// Hand collapses first, then reactions; hidden toggles move into the menu.
const COLLAPSIBLE_COUNT = 2
// Layout-neutral measuring wrapper: inherits the region's gap and refuses to
// flex-shrink so measured widths are natural content widths.
const measuredRow = css({
display: 'inline-flex',
alignItems: 'center',
gap: 'inherit',
flexShrink: 0,
})
export function MobileControlBar({ export function MobileControlBar({
onDeviceError, onDeviceError,
@@ -55,107 +36,50 @@ export function MobileControlBar({
const browserSupportsScreenSharing = supportsScreenSharing() const browserSupportsScreenSharing = supportsScreenSharing()
const { toggleEffects } = useSidePanel() const { toggleEffects } = useSidePanel()
const containerRef = useRef<HTMLDivElement>(null)
const { width } = useSize(containerRef)
const barRef = useRef<HTMLDivElement>(null)
const { width: barWidth } = useSize(barRef)
const collapsibleRef = useRef<HTMLDivElement>(null)
const { width: collapsibleWidth } = useSize(collapsibleRef)
const [hiddenCount, setHiddenCount] = useState(0)
const calibration = useRef<{ essential: number; slot: number }>()
useLayoutEffect(() => {
if (hiddenCount === 0 && collapsibleWidth > 0 && barRef.current) {
const gap = parseFloat(getComputedStyle(barRef.current).columnGap) || 0
calibration.current = {
essential: barWidth - collapsibleWidth - gap,
slot: (collapsibleWidth + gap) / COLLAPSIBLE_COUNT,
}
}
if (!calibration.current || width <= 0) return
const { essential, slot } = calibration.current
const fits = Math.floor((width - essential) / slot)
const next = Math.min(
COLLAPSIBLE_COUNT,
Math.max(0, COLLAPSIBLE_COUNT - fits)
)
if (next !== hiddenCount) setHiddenCount(next)
}, [barWidth, collapsibleWidth, hiddenCount, width, setHiddenCount])
const hideHand = hiddenCount >= 1
const hideReactions = hiddenCount >= 2
const room = useRoomContext()
const { toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
useRegisterKeyboardShortcut({
id: 'raise-hand',
handler: toggleRaisedHand,
})
useRegisterKeyboardShortcut({
id: 'reaction',
handler: reactionShortcutHandler,
})
const { data } = useConfig() const { data } = useConfig()
const closeMenu = () => setIsMenuOpened(false)
return ( return (
<> <>
<div <div
className={css({ className={css({
width: '100vw', width: '100vw',
display: 'flex',
padding: '1.125rem', padding: '1.125rem',
justifyContent: 'center',
})} })}
> >
<div <ControlBarRegion mobile>
ref={containerRef} <LeaveButton />
className={css({ <AudioDevicesControl
width: '100%', onDeviceError={(error) =>
display: 'flex', onDeviceError?.({ source: Track.Source.Microphone, error })
justifyContent: 'center', }
})} hideMenu={true}
> />
<ControlBarRegion mobile> <VideoDeviceControl
<div ref={barRef} className={measuredRow}> onDeviceError={(error) =>
<LeaveButton /> onDeviceError?.({ source: Track.Source.Camera, error })
<AudioDevicesControl }
onDeviceError={(error) => hideMenu={true}
onDeviceError?.({ source: Track.Source.Microphone, error }) />
} <ReactionsToggle />
hideMenu={true} <HandToggle />
/> <Button
<VideoDeviceControl id="room-options-trigger"
onDeviceError={(error) => square
onDeviceError?.({ source: Track.Source.Camera, error }) variant="primaryDark"
} aria-label={t('options.buttonLabel')}
hideMenu={true} tooltip={t('options.buttonLabel')}
/> onPress={() => setIsMenuOpened(true)}
{/* Unmounted when empty so it doesn't leave a stray gap. */} >
{!hideReactions && ( <RiMore2Line />
<div ref={collapsibleRef} className={measuredRow}> </Button>
<ReactionsToggle /> </ControlBarRegion>
{!hideHand && <HandToggle />}
</div>
)}
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
onPress={() => setIsMenuOpened(true)}
>
<RiMore2Line />
</Button>
</div>
</ControlBarRegion>
</div>
</div> </div>
<ResponsiveMenu isOpened={isMenuOpened} onClosed={closeMenu}> <ResponsiveMenu
isOpened={isMenuOpened}
onClosed={() => setIsMenuOpened(false)}
>
<div <div
className={css({ className={css({
display: 'flex', display: 'flex',
@@ -174,20 +98,6 @@ export function MobileControlBar({
}, },
})} })}
> >
{hideReactions && (
<ReactionsToggle
variant="primaryTextDark"
description={true}
onPress={closeMenu}
/>
)}
{hideHand && (
<HandToggle
variant="primaryTextDark"
description={true}
onPress={closeMenu}
/>
)}
{browserSupportsScreenSharing && ( {browserSupportsScreenSharing && (
<ScreenShareToggle <ScreenShareToggle
onDeviceError={(error) => onDeviceError={(error) =>
@@ -195,16 +105,25 @@ export function MobileControlBar({
} }
variant="primaryTextDark" variant="primaryTextDark"
description={true} description={true}
onPress={closeMenu} onPress={() => setIsMenuOpened(false)}
/> />
)} )}
<ChatToggle description={true} onPress={closeMenu} /> <ChatToggle
<ParticipantsToggle description={true} onPress={closeMenu} /> description={true}
<ToolsToggle description={true} onPress={closeMenu} /> onPress={() => setIsMenuOpened(false)}
/>
<ParticipantsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<ToolsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<Button <Button
onPress={() => { onPress={() => {
toggleEffects() toggleEffects()
closeMenu() setIsMenuOpened(false)
}} }}
variant="primaryTextDark" variant="primaryTextDark"
aria-label={t('options.items.effects')} aria-label={t('options.items.effects')}
@@ -221,7 +140,7 @@ export function MobileControlBar({
aria-label={t('options.items.feedback')} aria-label={t('options.items.feedback')}
description={true} description={true}
target="_blank" target="_blank"
onPress={closeMenu} onPress={() => setIsMenuOpened(false)}
> >
<RiMegaphoneLine size={20} /> <RiMegaphoneLine size={20} />
</LinkButton> </LinkButton>
@@ -229,7 +148,7 @@ export function MobileControlBar({
<Button <Button
onPress={() => { onPress={() => {
openSettingsDialog() openSettingsDialog()
closeMenu() setIsMenuOpened(false)
}} }}
variant="primaryTextDark" variant="primaryTextDark"
aria-label={t('options.items.settings')} aria-label={t('options.items.settings')}
@@ -238,7 +157,7 @@ export function MobileControlBar({
> >
<RiSettings3Line size={20} /> <RiSettings3Line size={20} />
</Button> </Button>
<CameraSwitchButton onPress={closeMenu} /> <CameraSwitchButton onPress={() => setIsMenuOpened(false)} />
</div> </div>
</div> </div>
</ResponsiveMenu> </ResponsiveMenu>
@@ -31,7 +31,6 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider' import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences' import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector' import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
/** /**
* @public * @public
@@ -121,7 +120,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<RoomSilentMicDetector /> <RoomSilentMicDetector />
<MediaStateObserver /> <MediaStateObserver />
<ChatProvider /> <ChatProvider />
<LobbyProvider />
<VideoResolutionSubscription /> <VideoResolutionSubscription />
<div <div
className="lk-video-conference" className="lk-video-conference"
@@ -1,9 +0,0 @@
export const VOICE_AUDIO_CONSTRAINTS = {
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
voiceIsolation: false,
sampleRate: 48000,
channelCount: 1,
sampleSize: 16,
} as const
@@ -1,8 +1,7 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
import { Center, Stack, styled, VStack } from '@/styled-system/jsx' import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Rating } from '@/features/rooms/components/Rating.tsx' import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter' import { useLocation } from 'wouter'
import { useMemo } from 'react' import { useMemo } from 'react'
@@ -15,18 +14,14 @@ const Heading = styled('h1', {
fontStyle: 'normal', fontStyle: 'normal',
fontStretch: 'normal', fontStretch: 'normal',
fontOpticalSizing: 'auto', fontOpticalSizing: 'auto',
fontSize: { base: '1.75rem', xsm: '2.3rem' }, fontSize: '2.3rem',
lineHeight: { base: '2.125rem', xsm: '2.5rem' }, lineHeight: '2.5rem',
letterSpacing: '0', letterSpacing: '0',
paddingBottom: { base: '1.5rem', xsm: '2rem' }, paddingBottom: '2rem',
textAlign: 'center', textAlign: 'center',
}, },
}) })
const buttonClass = css({
width: { base: '100%', xsm: 'auto' },
})
enum DisconnectReasonKey { enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity', DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved', ParticipantRemoved = 'participantRemoved',
@@ -59,31 +54,19 @@ const FeedbackRoute = () => {
return ( return (
<Screen layout="centered" footer={false}> <Screen layout="centered" footer={false}>
<Center width="100%"> <Center>
<VStack width="100%" paddingX="1rem"> <VStack>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading> <Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<Stack <HStack>
direction={{ base: 'column', xsm: 'row' }}
width={{ base: '100%', xsm: 'auto' }}
maxWidth="380px"
>
{showBackButton && ( {showBackButton && (
<Button <Button variant="secondary" onPress={() => window.history.back()}>
variant="secondary"
className={buttonClass}
onPress={() => window.history.back()}
>
{t('feedback.back')} {t('feedback.back')}
</Button> </Button>
)} )}
<Button <Button variant="primary" onPress={() => setLocation('/')}>
variant="primary"
className={buttonClass}
onPress={() => setLocation('/')}
>
{t('feedback.home')} {t('feedback.home')}
</Button> </Button>
</Stack> </HStack>
<Rating metadata={metadata} /> <Rating metadata={metadata} />
</VStack> </VStack>
</Center> </Center>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.29.0", "version": "1.28.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.29.0", "version": "1.28.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.1", "@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.29.0", "version": "1.28.0",
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.29.0", "version": "1.28.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sdk", "name": "sdk",
"version": "1.29.0", "version": "1.28.0",
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
"./library", "./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.29.0", "version": "1.28.0",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "summary" name = "summary"
version = "1.29.0" version = "1.28.0"
dependencies = [ dependencies = [
"fastapi[standard]>=0.105.0", "fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0", "uvicorn>=0.24.0",
+1 -8
View File
@@ -1,10 +1,9 @@
"""Analytics classes.""" """Analytics classes."""
import json import json
import socket
import time import time
from collections import Counter from collections import Counter
from functools import cached_property, lru_cache from functools import lru_cache
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
import redis import redis
@@ -34,10 +33,6 @@ class Analytics:
logger.info("Initialize analytics client") logger.info("Initialize analytics client")
self._client = Posthog(settings.posthog_api_key, settings.posthog_api_host) self._client = Posthog(settings.posthog_api_key, settings.posthog_api_host)
@cached_property
def _hostname(self):
return socket.gethostname()
@property @property
def is_disabled(self): def is_disabled(self):
"""Check if analytics client is disabled or not configured.""" """Check if analytics client is disabled or not configured."""
@@ -48,8 +43,6 @@ class Analytics:
if self.is_disabled: if self.is_disabled:
return return
properties = {**(properties or {}), "_hostname": self._hostname}
try: try:
self._client.capture( self._client.capture(
event_name, distinct_id=distinct_id, properties=properties event_name, distinct_id=distinct_id, properties=properties