mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-01 13:17:59 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c9a36ac7 | |||
| 35f2d3da86 | |||
| e204cd7f38 | |||
| 8d28343e80 | |||
| e1eb30d798 | |||
| 1bd2325ad2 | |||
| ebaf24c795 |
@@ -10,27 +10,7 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### 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
|
|
||||||
|
|
||||||
## [1.28.0] - 2026-08-24
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- 📈(frontend) track errors when starting or stopping a recording
|
- 📈(frontend) track errors when starting or stopping a recording
|
||||||
- 🚸(frontend) explain camera-in-use failures on the join screen
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -51,10 +31,6 @@ and this project adheres to
|
|||||||
- 🐛(frontend) stop init_virtual_background from firing on blur updates
|
- 🐛(frontend) stop init_virtual_background from firing on blur updates
|
||||||
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
|
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
|
||||||
- 🐛(frontend) fix joined notification tile no longer rendering properly
|
- 🐛(frontend) fix joined notification tile no longer rendering properly
|
||||||
- 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
|
|
||||||
- 🐛(frontend) handle Firefox/Windows AbortError on device start
|
|
||||||
- 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
|
|
||||||
- 🔇(frontend) suppress leaked WebSocket error events from livekit-client
|
|
||||||
|
|
||||||
## [1.27.0] - 2026-08-14
|
## [1.27.0] - 2026-08-14
|
||||||
|
|
||||||
@@ -62,7 +38,6 @@ and this project adheres to
|
|||||||
|
|
||||||
- 🔥(frontend) drop unused vendored ConnectionObserver
|
- 🔥(frontend) drop unused vendored ConnectionObserver
|
||||||
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
|
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
|
||||||
- ✨(summary) add hostname to analytics properties
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.28.0"
|
version = "1.27.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.28.0"
|
version = "1.27.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.28.0"
|
version = "1.27.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.28.0"
|
version = "1.27.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
Generated
+32
-44
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.28.0",
|
"version": "1.27.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.28.0",
|
"version": "1.27.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",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.28.0",
|
"version": "1.27.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",
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import type { CaptureResult } from 'posthog-js'
|
|||||||
|
|
||||||
const IGNORED_EXCEPTION_PATTERNS = [
|
const IGNORED_EXCEPTION_PATTERNS = [
|
||||||
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
|
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
|
||||||
// livekit-client leaks the raw WebSocket error Event as an unhandled
|
|
||||||
// rejection when the signal ws errors after connect (Firefox-heavy,
|
|
||||||
// coincides with signal reconnects). Carries zero diagnostic content —
|
|
||||||
// the close reason is already logged by the SDK.
|
|
||||||
// See: https://github.com/livekit/client-sdk-js/issues/2062
|
|
||||||
/^Event captured as exception with keys: isTrusted$/,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const shouldIgnoreException = (value: unknown): boolean =>
|
const shouldIgnoreException = (value: unknown): boolean =>
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ export const captureMediaEvent = async (
|
|||||||
| 'media-device-topology'
|
| 'media-device-topology'
|
||||||
| 'media-device-success'
|
| 'media-device-success'
|
||||||
| 'device-not-found'
|
| 'device-not-found'
|
||||||
| 'device-in-use'
|
|
||||||
| 'permissions-denied'
|
| 'permissions-denied'
|
||||||
| 'screen-share-permission-denied'
|
| 'screen-share-permission-denied'
|
||||||
| 'silent-mic-detected'
|
| 'silent-mic-detected'
|
||||||
|
|||||||
@@ -42,23 +42,6 @@ export type IceCandidateReport = {
|
|||||||
working: IceCandidatePair[]
|
working: IceCandidatePair[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* True when the selected pair does not carry media over plain UDP: the browser
|
|
||||||
* fell back to a TURN relay over TCP or TLS, or even the direct route is not
|
|
||||||
* UDP. Media still flows, but quality usually degrades under load.
|
|
||||||
*
|
|
||||||
* Accepts the loosely typed `data` stored on the step result; anything that is
|
|
||||||
* not an IceCandidateReport simply yields false.
|
|
||||||
*/
|
|
||||||
export const isSuboptimalRoute = (data: unknown): boolean => {
|
|
||||||
const selected = (data as IceCandidateReport | null | undefined)?.selected
|
|
||||||
if (!selected) return false
|
|
||||||
const transport = selected.local.relayProtocol ?? selected.local.protocol
|
|
||||||
// An unreported transport is not evidence of a bad route.
|
|
||||||
if (!transport) return false
|
|
||||||
return transport.toLowerCase() !== 'udp'
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROBE_WIDTH = 320
|
const PROBE_WIDTH = 320
|
||||||
const PROBE_HEIGHT = 180
|
const PROBE_HEIGHT = 180
|
||||||
const PROBE_FPS = 15
|
const PROBE_FPS = 15
|
||||||
|
|||||||
@@ -2,33 +2,18 @@ import type { ReactNode } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ProgressBar } from 'react-aria-components'
|
import { ProgressBar } from 'react-aria-components'
|
||||||
import { css, cx } from '@/styled-system/css'
|
import { css, cx } from '@/styled-system/css'
|
||||||
import { A } from '@/primitives'
|
|
||||||
import type { ConnectionTestStats } from '../types'
|
import type { ConnectionTestStats } from '../types'
|
||||||
import { statusSquareClass } from './stepAppearance'
|
import { statusSquareClass } from './stepAppearance'
|
||||||
|
|
||||||
/**
|
type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed'
|
||||||
* Network prerequisites (protocols, ports, TURN fallbacks), written for the
|
|
||||||
* reader's IT department rather than for the end user.
|
|
||||||
*/
|
|
||||||
const NETWORK_REQUIREMENTS_DOC_URL =
|
|
||||||
'https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/'
|
|
||||||
|
|
||||||
type SummaryState =
|
/** Only a failure earns a colour: everything else stays near-black. */
|
||||||
| 'idle'
|
|
||||||
| 'running'
|
|
||||||
| 'passed'
|
|
||||||
| 'partial'
|
|
||||||
| 'failed'
|
|
||||||
| 'warning'
|
|
||||||
|
|
||||||
/** Only a failure or a degraded route earns a colour: everything else stays near-black. */
|
|
||||||
const stateColorClass: Record<SummaryState, string> = {
|
const stateColorClass: Record<SummaryState, string> = {
|
||||||
idle: css({ color: 'greyscale.1000' }),
|
idle: css({ color: 'greyscale.1000' }),
|
||||||
running: css({ color: 'greyscale.1000' }),
|
running: css({ color: 'greyscale.1000' }),
|
||||||
passed: css({ color: 'greyscale.1000' }),
|
passed: css({ color: 'greyscale.1000' }),
|
||||||
partial: css({ color: 'greyscale.1000' }),
|
partial: css({ color: 'greyscale.1000' }),
|
||||||
failed: css({ color: 'danger.600' }),
|
failed: css({ color: 'danger.600' }),
|
||||||
warning: css({ color: 'warning' }),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardClass = css({
|
const cardClass = css({
|
||||||
@@ -191,31 +176,23 @@ const Counter = ({
|
|||||||
export const ConnectionTestSummary = ({
|
export const ConnectionTestSummary = ({
|
||||||
stats,
|
stats,
|
||||||
isRunning,
|
isRunning,
|
||||||
routeWarning = false,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
stats: ConnectionTestStats
|
stats: ConnectionTestStats
|
||||||
isRunning: boolean
|
isRunning: boolean
|
||||||
/** The selected ICE route is usable but not UDP (e.g. TURN over TCP/TLS). */
|
|
||||||
routeWarning?: boolean
|
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('connectionTest')
|
const { t } = useTranslation('connectionTest')
|
||||||
|
|
||||||
// A hard failure still outranks the route warning; the warning outranks
|
|
||||||
// 'partial' because a measured degraded route matters more than skipped
|
|
||||||
// camera or microphone checks.
|
|
||||||
const state: SummaryState = isRunning
|
const state: SummaryState = isRunning
|
||||||
? 'running'
|
? 'running'
|
||||||
: !stats.hasStarted
|
: !stats.hasStarted
|
||||||
? 'idle'
|
? 'idle'
|
||||||
: stats.failed > 0
|
: stats.failed > 0
|
||||||
? 'failed'
|
? 'failed'
|
||||||
: routeWarning
|
: stats.skipped > 0
|
||||||
? 'warning'
|
? 'partial'
|
||||||
: stats.skipped > 0
|
: 'passed'
|
||||||
? 'partial'
|
|
||||||
: 'passed'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={cardClass}>
|
<section className={cardClass}>
|
||||||
@@ -229,22 +206,7 @@ export const ConnectionTestSummary = ({
|
|||||||
: t(`summary.${state}`)}
|
: t(`summary.${state}`)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className={hintClass}>
|
<p className={hintClass}>{t(`summary.${state}Hint`)}</p>
|
||||||
{t(`summary.${state}Hint`)}
|
|
||||||
{state === 'warning' && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<A
|
|
||||||
href={NETWORK_REQUIREMENTS_DOC_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{t('summary.warningDocLink')}
|
|
||||||
</A>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.hasStarted && (
|
{stats.hasStarted && (
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
|
|||||||
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
||||||
summary: info.description,
|
summary: info.description,
|
||||||
logs: info.logs,
|
logs: info.logs,
|
||||||
// Only SelectedCandidateCheck sets `data` (the ICE candidate report); for
|
|
||||||
// the stock LiveKit checks it is undefined and this is a no-op.
|
|
||||||
data: info.data as Record<string, unknown> | undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { css } from '@/styled-system/css'
|
|||||||
import { Center, VStack } from '@/styled-system/jsx'
|
import { Center, VStack } from '@/styled-system/jsx'
|
||||||
import { Permissions } from '@/features/rooms/components/Permissions'
|
import { Permissions } from '@/features/rooms/components/Permissions'
|
||||||
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
|
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
|
||||||
import { isSuboptimalRoute } from '../checks/selectedCandidate'
|
|
||||||
import { ConnectionTestStepRow } from '../components/ConnectionTestStepRow'
|
import { ConnectionTestStepRow } from '../components/ConnectionTestStepRow'
|
||||||
import { ConnectionTestSummary } from '../components/ConnectionTestSummary'
|
import { ConnectionTestSummary } from '../components/ConnectionTestSummary'
|
||||||
import { CONNECTION_TEST_GROUPS, summarizeSteps } from '../types'
|
import { CONNECTION_TEST_GROUPS, summarizeSteps } from '../types'
|
||||||
@@ -71,13 +70,6 @@ const ConnectionTest = () => {
|
|||||||
() => new Map(steps.map((step) => [step.id, step] as const)),
|
() => new Map(steps.map((step) => [step.id, step] as const)),
|
||||||
[steps]
|
[steps]
|
||||||
)
|
)
|
||||||
// The summary headline downgrades to a warning when media flows but over a
|
|
||||||
// fallback route: TURN over TCP/TLS, or anything else that is not UDP.
|
|
||||||
const routeWarning = useMemo(() => {
|
|
||||||
const step = stepsById.get('selectedCandidate')
|
|
||||||
return step?.status === 'success' && isSuboptimalRoute(step.data)
|
|
||||||
}, [stepsById])
|
|
||||||
|
|
||||||
const isPublishVideoRunning =
|
const isPublishVideoRunning =
|
||||||
stepsById.get('publishVideo')?.status === 'running'
|
stepsById.get('publishVideo')?.status === 'running'
|
||||||
|
|
||||||
@@ -107,11 +99,7 @@ const ConnectionTest = () => {
|
|||||||
<CenteredContent withBackButton>
|
<CenteredContent withBackButton>
|
||||||
<Center>
|
<Center>
|
||||||
<VStack gap="1.5rem" maxWidth="40rem" width="100%">
|
<VStack gap="1.5rem" maxWidth="40rem" width="100%">
|
||||||
<ConnectionTestSummary
|
<ConnectionTestSummary stats={stats} isRunning={isRunning}>
|
||||||
stats={stats}
|
|
||||||
isRunning={isRunning}
|
|
||||||
routeWarning={routeWarning}
|
|
||||||
>
|
|
||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
// A disabled "run" button while the test runs is dead weight:
|
// A disabled "run" button while the test runs is dead weight:
|
||||||
// cancelling is the only thing left to do.
|
// cancelling is the only thing left to do.
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const controlBarRegion = cva({
|
|||||||
mobile: {
|
mobile: {
|
||||||
true: {
|
true: {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
width: '100%',
|
width: '330px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
type RoomOptions,
|
type RoomOptions,
|
||||||
VideoPresets,
|
VideoPresets,
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { getMediaDeviceFailure } from '@/features/rooms/livekit/utils/mediaPermissions'
|
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
@@ -43,7 +42,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 +114,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,
|
||||||
@@ -234,7 +231,7 @@ export const Conference = ({
|
|||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const failure = getMediaDeviceFailure(e)
|
const failure = MediaDeviceFailure.getFailure(e)
|
||||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||||
|
|
||||||
// connect() was aborted by a disconnect() before the join completed
|
// connect() was aborted by a disconnect() before the join completed
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||||
import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse'
|
|
||||||
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
||||||
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
||||||
import { SilentMicDetector } from './SilentMicDetector'
|
import { SilentMicDetector } from './SilentMicDetector'
|
||||||
@@ -219,14 +218,12 @@ const switchTrackDevice =
|
|||||||
function getPreviewMessages({
|
function getPreviewMessages({
|
||||||
cameraFound,
|
cameraFound,
|
||||||
cameraDenied,
|
cameraDenied,
|
||||||
cameraInUse,
|
|
||||||
micDenied,
|
micDenied,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
videoStarted,
|
videoStarted,
|
||||||
}: {
|
}: {
|
||||||
cameraFound: boolean
|
cameraFound: boolean
|
||||||
cameraDenied: boolean
|
cameraDenied: boolean
|
||||||
cameraInUse: boolean
|
|
||||||
micDenied: boolean
|
micDenied: boolean
|
||||||
videoEnabled: boolean
|
videoEnabled: boolean
|
||||||
videoStarted: boolean
|
videoStarted: boolean
|
||||||
@@ -238,9 +235,6 @@ function getPreviewMessages({
|
|||||||
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
||||||
return { hint: key, permissionsButtonLabel: key }
|
return { hint: key, permissionsButtonLabel: key }
|
||||||
}
|
}
|
||||||
if (cameraInUse) {
|
|
||||||
return { hint: 'cameraInUse', permissionsButtonLabel: null }
|
|
||||||
}
|
|
||||||
if (!videoEnabled) {
|
if (!videoEnabled) {
|
||||||
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
|
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
|
||||||
}
|
}
|
||||||
@@ -334,20 +328,18 @@ const VideoPreview = ({
|
|||||||
const cameraDenied = useCannotUseDevice('videoinput')
|
const cameraDenied = useCannotUseDevice('videoinput')
|
||||||
const micDenied = useCannotUseDevice('audioinput')
|
const micDenied = useCannotUseDevice('audioinput')
|
||||||
const cameraMissing = useDeviceMissing('videoinput')
|
const cameraMissing = useDeviceMissing('videoinput')
|
||||||
const cameraInUse = useDeviceInUse('videoinput')
|
|
||||||
|
|
||||||
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
||||||
|
|
||||||
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
||||||
cameraFound: !cameraMissing,
|
cameraFound: !cameraMissing,
|
||||||
cameraDenied,
|
cameraDenied,
|
||||||
cameraInUse,
|
|
||||||
micDenied,
|
micDenied,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
videoStarted,
|
videoStarted,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isError = cameraMissing || cameraDenied || cameraInUse
|
const isError = cameraMissing || cameraDenied
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.previewFrame}>
|
<div className={styles.previewFrame}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import {
|
|
||||||
clearDeviceInUseForOtherDevice,
|
|
||||||
deviceAvailabilityStore,
|
|
||||||
} from '@/stores/deviceAvailability'
|
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
|
||||||
import { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
|
|
||||||
import type { PermissionKind } from '@/stores/permissions'
|
|
||||||
|
|
||||||
const RETRY_INTERVAL_MS = 30_000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* There is no browser event for "another app released the device", so
|
|
||||||
* while a device is flagged in use, re-probe it periodically. The probe
|
|
||||||
* targets the currently selected device (the flag is about what the app
|
|
||||||
* would acquire, not about any device of the kind). Only clears the flag
|
|
||||||
* (the toggle becomes usable again); it never re-enables the device on
|
|
||||||
* the user's behalf.
|
|
||||||
*/
|
|
||||||
export function useWatchDeviceReleased() {
|
|
||||||
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
|
||||||
const { audioDeviceId, videoDeviceId } = useSnapshot(userChoicesStore)
|
|
||||||
useWatchKind('camera', cameraInUse, videoDeviceId)
|
|
||||||
useWatchKind('microphone', microphoneInUse, audioDeviceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function useWatchKind(
|
|
||||||
kind: PermissionKind,
|
|
||||||
inUse: boolean,
|
|
||||||
selectedDeviceId?: string
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (inUse) clearDeviceInUseForOtherDevice(kind, selectedDeviceId)
|
|
||||||
}, [kind, inUse, selectedDeviceId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!inUse) return
|
|
||||||
let stopped = false
|
|
||||||
let timer: number | undefined
|
|
||||||
const tick = async () => {
|
|
||||||
await probeDeviceReleased(kind, selectedDeviceId || undefined)
|
|
||||||
if (!stopped) {
|
|
||||||
timer = window.setTimeout(tick, RETRY_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
timer = window.setTimeout(tick, RETRY_INTERVAL_MS)
|
|
||||||
return () => {
|
|
||||||
stopped = true
|
|
||||||
window.clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [kind, inUse, selectedDeviceId])
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
+6
-12
@@ -5,18 +5,16 @@ import { css } from '@/styled-system/css'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
type PermissionNeededButtonProps = {
|
type PermissionNeededButtonProps = {
|
||||||
tooltip?: string | null
|
tooltip?: string
|
||||||
onPress?: (() => void) | null
|
onPress?: () => void
|
||||||
ariaLabel?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PermissionNeededButton = ({
|
export const PermissionNeededButton = ({
|
||||||
tooltip,
|
tooltip,
|
||||||
onPress,
|
onPress,
|
||||||
ariaLabel,
|
|
||||||
}: PermissionNeededButtonProps) => {
|
}: PermissionNeededButtonProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
||||||
const label = tooltip === undefined ? t('tooltip') : tooltip
|
const label = tooltip ?? t('tooltip')
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
@@ -28,13 +26,9 @@ export const PermissionNeededButton = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
aria-label={ariaLabel ?? label ?? t('ariaLabel')}
|
aria-label={tooltip ? label : t('ariaLabel')}
|
||||||
tooltip={label ?? undefined}
|
tooltip={label}
|
||||||
onPress={
|
onPress={onPress ?? (() => openPermissionsDialog())}
|
||||||
onPress === undefined
|
|
||||||
? () => openPermissionsDialog()
|
|
||||||
: (onPress ?? undefined)
|
|
||||||
}
|
|
||||||
variant="permission"
|
variant="permission"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
+19
-49
@@ -20,10 +20,8 @@ import { openPermissionsDialog } from '@/stores/permissions'
|
|||||||
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
|
|
||||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||||
import { requestDevicePermission } from '../../../utils/mediaPermissions'
|
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
|
||||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||||
import type {
|
import type {
|
||||||
@@ -99,51 +97,38 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
const deviceIcons = useDeviceIcons(kind)
|
const deviceIcons = useDeviceIcons(kind)
|
||||||
const cannotUseDevice = useCannotUseDevice(kind)
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
const deviceMissing = useDeviceMissing(kind)
|
const deviceMissing = useDeviceMissing(kind)
|
||||||
const deviceInUse = useDeviceInUse(kind)
|
|
||||||
const explainDeviceInUse = deviceInUse && context === 'room'
|
|
||||||
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||||
const silentMicWarning =
|
const silentMicWarning =
|
||||||
kind === 'audioinput' &&
|
kind === 'audioinput' &&
|
||||||
silentMicStatus === 'silent' &&
|
silentMicStatus === 'silent' &&
|
||||||
!cannotUseDevice &&
|
!cannotUseDevice &&
|
||||||
!deviceMissing &&
|
!deviceMissing
|
||||||
!deviceInUse
|
|
||||||
const deviceShortcut = useDeviceShortcut(kind)
|
const deviceShortcut = useDeviceShortcut(kind)
|
||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const isRequestingPermission = useRef(false)
|
const isRequestingPermission = useRef(false)
|
||||||
const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
|
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
|
||||||
|
|
||||||
const mediaPath = context === 'join' ? 'join_preview' : 'room'
|
|
||||||
|
|
||||||
const onPress = async () => {
|
const onPress = async () => {
|
||||||
if (!enabled && deviceMissing) {
|
if (!enabled && deviceMissing) {
|
||||||
setAlertError(MediaDeviceFailure.NotFound)
|
setShowDeviceNotFound(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!cannotUseDevice && !deviceInUse) {
|
if (!cannotUseDevice) {
|
||||||
toggle()
|
toggle()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isRequestingPermission.current) return
|
if (isRequestingPermission.current) return
|
||||||
isRequestingPermission.current = true
|
isRequestingPermission.current = true
|
||||||
try {
|
try {
|
||||||
const selectedDeviceId = cannotUseDevice
|
const granted = await requestDevicePermission(
|
||||||
? undefined
|
|
||||||
: kind === 'videoinput'
|
|
||||||
? userChoicesStore.videoDeviceId
|
|
||||||
: userChoicesStore.audioDeviceId
|
|
||||||
const acquired = await requestDevicePermission(
|
|
||||||
kind,
|
kind,
|
||||||
mediaPath,
|
context === 'join' ? 'join_preview' : 'room'
|
||||||
selectedDeviceId || undefined
|
|
||||||
)
|
)
|
||||||
if (acquired) {
|
if (granted) {
|
||||||
toggle()
|
toggle()
|
||||||
} else if (cannotUseDevice) {
|
} else {
|
||||||
openPermissionsDialog(kind)
|
openPermissionsDialog(kind)
|
||||||
} else if (explainDeviceInUse) {
|
|
||||||
setAlertError(MediaDeviceFailure.DeviceInUse)
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isRequestingPermission.current = false
|
isRequestingPermission.current = false
|
||||||
@@ -194,34 +179,13 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
return <ActiveSpeakerWrapper />
|
return <ActiveSpeakerWrapper />
|
||||||
}
|
}
|
||||||
|
|
||||||
const getToggleTooltip = () => {
|
|
||||||
if (deviceMissing) return t(`deviceNotFound.${kind}`)
|
|
||||||
if (explainDeviceInUse) return t(`deviceInUse.${kind}`)
|
|
||||||
if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' })
|
|
||||||
return toggleLabel
|
|
||||||
}
|
|
||||||
const toggleTooltip = getToggleTooltip()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
{(cannotUseDevice || deviceMissing) && (
|
{(cannotUseDevice || deviceMissing) && (
|
||||||
<PermissionNeededButton
|
<PermissionNeededButton
|
||||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||||
onPress={
|
onPress={
|
||||||
deviceMissing
|
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
|
||||||
? () => setAlertError(MediaDeviceFailure.NotFound)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{deviceInUse && (
|
|
||||||
<PermissionNeededButton
|
|
||||||
tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : null}
|
|
||||||
ariaLabel={t(`deviceInUse.${kind}`)}
|
|
||||||
onPress={
|
|
||||||
explainDeviceInUse
|
|
||||||
? () => setAlertError(MediaDeviceFailure.DeviceInUse)
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -240,16 +204,22 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
shySelected
|
shySelected
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
aria-label={toggleLabel}
|
aria-label={toggleLabel}
|
||||||
tooltip={toggleTooltip}
|
tooltip={
|
||||||
|
deviceMissing
|
||||||
|
? t(`deviceNotFound.${kind}`)
|
||||||
|
: cannotUseDevice
|
||||||
|
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||||
|
: toggleLabel
|
||||||
|
}
|
||||||
{...computedToggleButtonProps}
|
{...computedToggleButtonProps}
|
||||||
{...overrideToggleButtonProps}
|
{...overrideToggleButtonProps}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<MediaDeviceErrorAlert
|
<MediaDeviceErrorAlert
|
||||||
error={alertError}
|
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
onClose={() => setAlertError(null)}
|
onClose={() => setShowDeviceNotFound(false)}
|
||||||
/>
|
/>
|
||||||
</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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
|
|
||||||
import { useCannotUseDevice } from './useCannotUseDevice'
|
|
||||||
import { useDeviceMissing } from './useDeviceMissing'
|
|
||||||
|
|
||||||
export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
|
|
||||||
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
|
||||||
const cannotUseDevice = useCannotUseDevice(kind)
|
|
||||||
const deviceMissing = useDeviceMissing(kind)
|
|
||||||
|
|
||||||
if (cannotUseDevice || deviceMissing) return false
|
|
||||||
|
|
||||||
switch (kind) {
|
|
||||||
case 'videoinput':
|
|
||||||
return cameraInUse
|
|
||||||
case 'audioinput':
|
|
||||||
return microphoneInUse
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,15 +10,16 @@ import {
|
|||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { BackgroundProcessorFactory } from '../components/blur'
|
import { BackgroundProcessorFactory } from '../components/blur'
|
||||||
import {
|
import {
|
||||||
|
classifyPermissionError,
|
||||||
|
isLikelySystemNotFound,
|
||||||
isSystemPermissionError,
|
isSystemPermissionError,
|
||||||
|
noteGumSuccess,
|
||||||
|
notePermissionDeniedFromGum,
|
||||||
|
noteSystemPermissionDenied,
|
||||||
type PermissionKind,
|
type PermissionKind,
|
||||||
} from '@/stores/permissions'
|
} from '@/stores/permissions'
|
||||||
import {
|
import { getOS } from '@/utils/os'
|
||||||
getMediaDeviceFailure,
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
noteDeviceReady,
|
|
||||||
onMediaPermissionError,
|
|
||||||
} from '../utils/mediaPermissions'
|
|
||||||
import { VOICE_AUDIO_CONSTRAINTS } from '../utils/constants'
|
|
||||||
import {
|
import {
|
||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
@@ -28,6 +29,74 @@ 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
|
||||||
|
|
||||||
|
const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
|
||||||
|
audioinput: 'microphone',
|
||||||
|
videoinput: 'camera',
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaPath = 'join_preview' | 'room'
|
||||||
|
|
||||||
|
const onMediaPermissionError = (
|
||||||
|
e: Error,
|
||||||
|
kind?: PermissionKind,
|
||||||
|
path: MediaPath = 'join_preview'
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
|
||||||
|
) {
|
||||||
|
void classifyPermissionError(e, kind).then((scope) => {
|
||||||
|
if (scope === 'system') {
|
||||||
|
noteSystemPermissionDenied(kind)
|
||||||
|
} else {
|
||||||
|
notePermissionDeniedFromGum(kind)
|
||||||
|
}
|
||||||
|
captureMediaEvent('permissions-denied', {
|
||||||
|
path,
|
||||||
|
kind,
|
||||||
|
denied_scope: scope,
|
||||||
|
os: getOS(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) {
|
||||||
|
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
|
||||||
|
// settings, missing Android app permissions).
|
||||||
|
void isLikelySystemNotFound(e, kind).then((system) => {
|
||||||
|
if (system) {
|
||||||
|
noteSystemPermissionDenied(kind)
|
||||||
|
captureMediaEvent('permissions-denied', {
|
||||||
|
path,
|
||||||
|
kind,
|
||||||
|
denied_scope: 'system',
|
||||||
|
os: getOS(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
captureMediaEvent('device-not-found', { path, kind })
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
|
||||||
|
reportError(
|
||||||
|
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
||||||
|
e,
|
||||||
|
{ path, kind }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -35,6 +104,24 @@ const disableVideo = () => saveVideoInputEnabled(false)
|
|||||||
const stopAll = (stream: MediaStream) =>
|
const stopAll = (stream: MediaStream) =>
|
||||||
stream.getTracks().forEach((track) => track.stop())
|
stream.getTracks().forEach((track) => track.stop())
|
||||||
|
|
||||||
|
export const requestDevicePermission = async (
|
||||||
|
kind: 'audioinput' | 'videoinput',
|
||||||
|
path: MediaPath = 'join_preview'
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const track =
|
||||||
|
kind === 'audioinput'
|
||||||
|
? await createLocalAudioTrack()
|
||||||
|
: await createLocalVideoTrack()
|
||||||
|
track.stop()
|
||||||
|
noteGumSuccess(PERMISSION_KIND[kind])
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type WarmupState = {
|
type WarmupState = {
|
||||||
audioReady: boolean
|
audioReady: boolean
|
||||||
videoReady: boolean
|
videoReady: boolean
|
||||||
@@ -71,11 +158,11 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
video: true,
|
video: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
noteDeviceReady()
|
noteGumSuccess()
|
||||||
bothReady()
|
bothReady()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
getMediaDeviceFailure(error as Error) ===
|
MediaDeviceFailure.getFailure(error as Error) ===
|
||||||
MediaDeviceFailure.PermissionDenied &&
|
MediaDeviceFailure.PermissionDenied &&
|
||||||
!isSystemPermissionError(error)
|
!isSystemPermissionError(error)
|
||||||
) {
|
) {
|
||||||
@@ -93,7 +180,7 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
.getUserMedia({ audio: true })
|
.getUserMedia({ audio: true })
|
||||||
.then((stream) => {
|
.then((stream) => {
|
||||||
stopAll(stream)
|
stopAll(stream)
|
||||||
noteDeviceReady('microphone')
|
noteGumSuccess('microphone')
|
||||||
})
|
})
|
||||||
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
||||||
.finally(() =>
|
.finally(() =>
|
||||||
@@ -103,7 +190,7 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
.getUserMedia({ video: true })
|
.getUserMedia({ video: true })
|
||||||
.then((stream) => {
|
.then((stream) => {
|
||||||
stopAll(stream)
|
stopAll(stream)
|
||||||
noteDeviceReady('camera')
|
noteGumSuccess('camera')
|
||||||
})
|
})
|
||||||
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
||||||
.finally(() =>
|
.finally(() =>
|
||||||
@@ -122,14 +209,12 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
|||||||
enabled,
|
enabled,
|
||||||
create,
|
create,
|
||||||
permissionKind,
|
permissionKind,
|
||||||
deviceId,
|
|
||||||
onFailure,
|
onFailure,
|
||||||
}: {
|
}: {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
create: () => Promise<T>
|
create: () => Promise<T>
|
||||||
permissionKind: PermissionKind
|
permissionKind: PermissionKind
|
||||||
deviceId?: string
|
|
||||||
onFailure: () => void
|
onFailure: () => void
|
||||||
}): T | null {
|
}): T | null {
|
||||||
const [track, setTrack] = useState<T | null>(null)
|
const [track, setTrack] = useState<T | null>(null)
|
||||||
@@ -142,27 +227,21 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
create()
|
create()
|
||||||
.then((newTrack) => {
|
.then((newTrack) => {
|
||||||
|
noteGumSuccess(permissionKind)
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
newTrack.stop()
|
newTrack.stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
noteDeviceReady(permissionKind)
|
|
||||||
setTrack(newTrack)
|
setTrack(newTrack)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return
|
onMediaPermissionError(error as Error, permissionKind)
|
||||||
onMediaPermissionError(
|
|
||||||
error as Error,
|
|
||||||
permissionKind,
|
|
||||||
'join_preview',
|
|
||||||
deviceId
|
|
||||||
)
|
|
||||||
onFailure()
|
onFailure()
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [ready, enabled, track, create, permissionKind, deviceId, onFailure])
|
}, [ready, enabled, track, create, permissionKind, onFailure])
|
||||||
|
|
||||||
// Release on toggle-off so the LED turns off.
|
// Release on toggle-off so the LED turns off.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -236,7 +315,6 @@ export function useJoinTracks(): {
|
|||||||
enabled: audioEnabled,
|
enabled: audioEnabled,
|
||||||
create: createAudio,
|
create: createAudio,
|
||||||
permissionKind: 'microphone',
|
permissionKind: 'microphone',
|
||||||
deviceId: audioDeviceId,
|
|
||||||
onFailure: disableAudio,
|
onFailure: disableAudio,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -245,7 +323,6 @@ export function useJoinTracks(): {
|
|||||||
enabled: videoEnabled,
|
enabled: videoEnabled,
|
||||||
create: createVideo,
|
create: createVideo,
|
||||||
permissionKind: 'camera',
|
permissionKind: 'camera',
|
||||||
deviceId: videoDeviceId,
|
|
||||||
onFailure: disableVideo,
|
onFailure: disableVideo,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import {
|
import { MediaDeviceFailure, RoomEvent } from 'livekit-client'
|
||||||
type LocalTrackPublication,
|
|
||||||
MediaDeviceFailure,
|
|
||||||
RoomEvent,
|
|
||||||
Track,
|
|
||||||
} from 'livekit-client'
|
|
||||||
import {
|
import {
|
||||||
PERMISSION_BY_DEVICE_KIND,
|
PERMISSION_BY_DEVICE_KIND,
|
||||||
type PermissionDeniedScope,
|
type PermissionDeniedScope,
|
||||||
@@ -15,13 +10,8 @@ import {
|
|||||||
notePermissionDeniedFromGum,
|
notePermissionDeniedFromGum,
|
||||||
noteSystemPermissionDenied,
|
noteSystemPermissionDenied,
|
||||||
} from '@/stores/permissions'
|
} from '@/stores/permissions'
|
||||||
import {
|
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
|
||||||
clearDeviceInUse,
|
|
||||||
noteDeviceInUse,
|
|
||||||
syncDeviceAvailability,
|
|
||||||
} from '@/stores/deviceAvailability'
|
|
||||||
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
||||||
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
|
|
||||||
import { getOS } from '@/utils/os'
|
import { getOS } from '@/utils/os'
|
||||||
|
|
||||||
type MediaDeviceAlert = {
|
type MediaDeviceAlert = {
|
||||||
@@ -31,11 +21,6 @@ type MediaDeviceAlert = {
|
|||||||
|
|
||||||
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
|
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
|
||||||
|
|
||||||
const PERMISSION_BY_SOURCE: Partial<Record<Track.Source, PermissionKind>> = {
|
|
||||||
[Track.Source.Camera]: 'camera',
|
|
||||||
[Track.Source.Microphone]: 'microphone',
|
|
||||||
}
|
|
||||||
|
|
||||||
const capturePermissionsDenied = (
|
const capturePermissionsDenied = (
|
||||||
scope: PermissionDeniedScope,
|
scope: PermissionDeniedScope,
|
||||||
kind?: PermissionKind
|
kind?: PermissionKind
|
||||||
@@ -64,7 +49,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
|
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
|
||||||
const failure = getMediaDeviceFailure(error)
|
const failure = MediaDeviceFailure.getFailure(error)
|
||||||
if (!failure) return
|
if (!failure) return
|
||||||
if (failure != MediaDeviceFailure.Other) {
|
if (failure != MediaDeviceFailure.Other) {
|
||||||
void captureMediaEvent('media-device-error', {
|
void captureMediaEvent('media-device-error', {
|
||||||
@@ -78,7 +63,6 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
|||||||
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
||||||
switch (failure) {
|
switch (failure) {
|
||||||
case MediaDeviceFailure.DeviceInUse:
|
case MediaDeviceFailure.DeviceInUse:
|
||||||
if (permissionKind) noteDeviceInUse(permissionKind)
|
|
||||||
setAlert({ error: failure, kind })
|
setAlert({ error: failure, kind })
|
||||||
break
|
break
|
||||||
case MediaDeviceFailure.NotFound:
|
case MediaDeviceFailure.NotFound:
|
||||||
@@ -108,16 +92,9 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onTrackPublished = (publication: LocalTrackPublication) => {
|
|
||||||
const permissionKind = PERMISSION_BY_SOURCE[publication.source]
|
|
||||||
if (permissionKind) clearDeviceInUse(permissionKind)
|
|
||||||
}
|
|
||||||
room.on(RoomEvent.MediaDevicesError, onDeviceError)
|
room.on(RoomEvent.MediaDevicesError, onDeviceError)
|
||||||
room.on(RoomEvent.LocalTrackPublished, onTrackPublished)
|
|
||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.MediaDevicesError, onDeviceError)
|
room.off(RoomEvent.MediaDevicesError, onDeviceError)
|
||||||
room.off(RoomEvent.LocalTrackPublished, onTrackPublished)
|
|
||||||
clearDeviceInUse()
|
|
||||||
}
|
}
|
||||||
}, [room])
|
}, [room])
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { isWeb } from '@livekit/components-core'
|
import { isWeb } from '@livekit/components-core'
|
||||||
import { MediaDeviceFailure, Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ConnectionStateToast,
|
ConnectionStateToast,
|
||||||
@@ -31,7 +30,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
|
||||||
@@ -102,11 +100,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getMediaDeviceFailure(error) !== MediaDeviceFailure.Other) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reportError('device_switch_failure', error, {
|
reportError('device_switch_failure', error, {
|
||||||
at: 'ControlBar.onDeviceError',
|
at: 'ControlBar.onDeviceError',
|
||||||
source,
|
source,
|
||||||
@@ -121,7 +114,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,168 +0,0 @@
|
|||||||
import {
|
|
||||||
createLocalAudioTrack,
|
|
||||||
createLocalVideoTrack,
|
|
||||||
MediaDeviceFailure,
|
|
||||||
} from 'livekit-client'
|
|
||||||
import {
|
|
||||||
classifyPermissionError,
|
|
||||||
isLikelySystemNotFound,
|
|
||||||
noteGumSuccess,
|
|
||||||
notePermissionDeniedFromGum,
|
|
||||||
noteSystemPermissionDenied,
|
|
||||||
type PermissionKind,
|
|
||||||
} from '@/stores/permissions'
|
|
||||||
import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceAvailability'
|
|
||||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
|
||||||
import { getOS } from '@/utils/os'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared handling of getUserMedia() outcomes, used by both:
|
|
||||||
* - the join preview (`useJoinTracks`: warmup + local track acquisition)
|
|
||||||
* - the in-room device toggle (`ToggleDevice`, when permission is missing)
|
|
||||||
*/
|
|
||||||
export type MediaPath = 'join_preview' | 'room'
|
|
||||||
|
|
||||||
const DEVICE_START_FAILURE =
|
|
||||||
/^(Starting (video|audio)input failed|Timeout starting (video|audio) source)/i
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LiveKit only maps NotReadableError/TrackStartError to DeviceInUse.
|
|
||||||
* Firefox reports a device held by another app as
|
|
||||||
* `AbortError: Starting videoinput failed` (or audioinput), which LiveKit
|
|
||||||
* classifies as Other. Normalise it here; every classification in the app
|
|
||||||
* should go through this instead of MediaDeviceFailure.getFailure.
|
|
||||||
*/
|
|
||||||
export const getMediaDeviceFailure = (
|
|
||||||
error: Error
|
|
||||||
): MediaDeviceFailure | undefined => {
|
|
||||||
const failure = MediaDeviceFailure.getFailure(error)
|
|
||||||
if (
|
|
||||||
failure === MediaDeviceFailure.Other &&
|
|
||||||
error.name === 'AbortError' &&
|
|
||||||
DEVICE_START_FAILURE.test(error.message)
|
|
||||||
) {
|
|
||||||
return MediaDeviceFailure.DeviceInUse
|
|
||||||
}
|
|
||||||
return failure
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PERMISSION_KIND: Record<
|
|
||||||
'audioinput' | 'videoinput',
|
|
||||||
PermissionKind
|
|
||||||
> = {
|
|
||||||
audioinput: 'microphone',
|
|
||||||
videoinput: 'camera',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const noteDeviceReady = (kind?: PermissionKind) => {
|
|
||||||
noteGumSuccess(kind)
|
|
||||||
clearDeviceInUse(kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const onMediaPermissionError = (
|
|
||||||
e: Error,
|
|
||||||
kind?: PermissionKind,
|
|
||||||
path: MediaPath = 'join_preview',
|
|
||||||
deviceId?: string
|
|
||||||
) => {
|
|
||||||
const failure = getMediaDeviceFailure(e)
|
|
||||||
|
|
||||||
if (failure === MediaDeviceFailure.PermissionDenied) {
|
|
||||||
void classifyPermissionError(e, kind).then((scope) => {
|
|
||||||
if (scope === 'system') {
|
|
||||||
noteSystemPermissionDenied(kind)
|
|
||||||
} else {
|
|
||||||
notePermissionDeniedFromGum(kind)
|
|
||||||
}
|
|
||||||
captureMediaEvent('permissions-denied', {
|
|
||||||
path,
|
|
||||||
kind,
|
|
||||||
denied_scope: scope,
|
|
||||||
os: getOS(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failure === MediaDeviceFailure.NotFound) {
|
|
||||||
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
|
|
||||||
// settings, missing Android app permissions).
|
|
||||||
void isLikelySystemNotFound(e, kind).then((system) => {
|
|
||||||
if (system) {
|
|
||||||
noteSystemPermissionDenied(kind)
|
|
||||||
captureMediaEvent('permissions-denied', {
|
|
||||||
path,
|
|
||||||
kind,
|
|
||||||
denied_scope: 'system',
|
|
||||||
os: getOS(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
captureMediaEvent('device-not-found', { path, kind })
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failure === MediaDeviceFailure.DeviceInUse) {
|
|
||||||
noteDeviceInUse(kind, deviceId)
|
|
||||||
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Other" is still reported as an error.
|
|
||||||
reportError(
|
|
||||||
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
|
||||||
e,
|
|
||||||
{ path, kind }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Silent availability check for a device that was reported "in use":
|
|
||||||
* acquires and releases it without any error reporting, so it can be
|
|
||||||
* polled. Clears the in-use flag on success.
|
|
||||||
*/
|
|
||||||
export const probeDeviceReleased = async (
|
|
||||||
kind: PermissionKind,
|
|
||||||
deviceId?: string
|
|
||||||
): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const constraint = deviceId ? { deviceId: { exact: deviceId } } : true
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(
|
|
||||||
kind === 'camera' ? { video: constraint } : { audio: constraint }
|
|
||||||
)
|
|
||||||
stream.getTracks().forEach((track) => track.stop())
|
|
||||||
noteDeviceReady(kind)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers the browser permission prompt for one device kind by acquiring
|
|
||||||
* and immediately releasing a track. Resolves to whether access was granted.
|
|
||||||
*/
|
|
||||||
export const requestDevicePermission = async (
|
|
||||||
kind: 'audioinput' | 'videoinput',
|
|
||||||
path: MediaPath = 'join_preview',
|
|
||||||
deviceId?: string
|
|
||||||
): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const track =
|
|
||||||
kind === 'audioinput'
|
|
||||||
? await createLocalAudioTrack({ deviceId })
|
|
||||||
: await createLocalVideoTrack({ deviceId })
|
|
||||||
track.stop()
|
|
||||||
noteDeviceReady(PERMISSION_KIND[kind])
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
onMediaPermissionError(
|
|
||||||
error as Error,
|
|
||||||
PERMISSION_KIND[kind],
|
|
||||||
path,
|
|
||||||
deviceId
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { useConfig } from '@/api/useConfig.ts'
|
import { useConfig } from '@/api/useConfig.ts'
|
||||||
import { LogLevel, setLogLevel } from 'livekit-client'
|
import { LogLevel, setLogLevel } from 'livekit-client'
|
||||||
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
|
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
|
||||||
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
|
|
||||||
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
|
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
|
||||||
|
|
||||||
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||||
@@ -50,7 +49,6 @@ const Room = () => {
|
|||||||
|
|
||||||
useKeyboardShortcuts()
|
useKeyboardShortcuts()
|
||||||
useWatchDeviceAvailability()
|
useWatchDeviceAvailability()
|
||||||
useWatchDeviceReleased()
|
|
||||||
|
|
||||||
const clearRouterState = () => {
|
const clearRouterState = () => {
|
||||||
if (window?.history?.state) {
|
if (window?.history?.state) {
|
||||||
|
|||||||
@@ -46,9 +46,6 @@
|
|||||||
"passedHint": "Ihr Browser, Ihre Geräte und Ihr Netzwerk sind für eine Besprechung bereit.",
|
"passedHint": "Ihr Browser, Ihre Geräte und Ihr Netzwerk sind für eine Besprechung bereit.",
|
||||||
"partial": "Teilweiser Test",
|
"partial": "Teilweiser Test",
|
||||||
"partialHint": "Einige Prüfungen wurden übersprungen. Erlauben Sie den Zugriff auf Ihre Kamera und Ihr Mikrofon, um diese zu testen.",
|
"partialHint": "Einige Prüfungen wurden übersprungen. Erlauben Sie den Zugriff auf Ihre Kamera und Ihr Mikrofon, um diese zu testen.",
|
||||||
"warning": "Verbindung nicht optimal",
|
|
||||||
"warningHint": "Achtung: Sie befinden sich nicht in optimalen Bedingungen für die Nutzung des Tools. Ihre Medien werden nicht über UDP übertragen (Rückgriff auf ein TURN-Relay über TCP oder TLS), was die Audio- und Videoqualität beeinträchtigen kann.",
|
|
||||||
"warningDocLink": "Technische Dokumentation für Ihre IT-Abteilung",
|
|
||||||
"failed_one": "{{count}} Prüfung fehlgeschlagen",
|
"failed_one": "{{count}} Prüfung fehlgeschlagen",
|
||||||
"failed_other": "{{count}} Prüfungen fehlgeschlagen",
|
"failed_other": "{{count}} Prüfungen fehlgeschlagen",
|
||||||
"failedHint": "Öffnen Sie die fehlgeschlagenen Prüfungen für weitere Details und senden Sie den Bericht an Ihre IT-Abteilung."
|
"failedHint": "Öffnen Sie die fehlgeschlagenen Prüfungen für weitere Details und senden Sie den Bericht an Ihre IT-Abteilung."
|
||||||
|
|||||||
@@ -16,10 +16,6 @@
|
|||||||
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||||
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
||||||
},
|
},
|
||||||
"deviceInUse": {
|
|
||||||
"videoinput": "Kamera nicht verfügbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
|
||||||
"audioinput": "Mikrofon nicht verfügbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet."
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audioeinstellungen",
|
"audio": "Audioeinstellungen",
|
||||||
"video": "Videoeinstellungen"
|
"video": "Videoeinstellungen"
|
||||||
@@ -71,7 +67,6 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||||
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||||
"cameraInUse": "Deine Kamera ist nicht verfügbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
|
||||||
"cameraStarting": "Kamera wird gestartet…",
|
"cameraStarting": "Kamera wird gestartet…",
|
||||||
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
||||||
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
||||||
|
|||||||
@@ -46,9 +46,6 @@
|
|||||||
"passedHint": "Your browser, your devices and your network are ready for a meeting.",
|
"passedHint": "Your browser, your devices and your network are ready for a meeting.",
|
||||||
"partial": "Partially tested",
|
"partial": "Partially tested",
|
||||||
"partialHint": "Some checks were skipped. Allow access to your camera and microphone to test them.",
|
"partialHint": "Some checks were skipped. Allow access to your camera and microphone to test them.",
|
||||||
"warning": "Suboptimal connection",
|
|
||||||
"warningHint": "Warning: you are not in optimal conditions to use the tool. Your media is not carried over UDP (it falls back to a TURN relay over TCP or TLS), which can degrade audio and video quality.",
|
|
||||||
"warningDocLink": "Technical documentation for your IT department",
|
|
||||||
"failed_one": "{{count}} check failed",
|
"failed_one": "{{count}} check failed",
|
||||||
"failed_other": "{{count}} checks failed",
|
"failed_other": "{{count}} checks failed",
|
||||||
"failedHint": "Open the failed checks below for details, then send the report to your IT department."
|
"failedHint": "Open the failed checks below for details, then send the report to your IT department."
|
||||||
|
|||||||
@@ -16,10 +16,6 @@
|
|||||||
"videoinput": "No camera detected. Check that it is properly wired.",
|
"videoinput": "No camera detected. Check that it is properly wired.",
|
||||||
"audioinput": "No microphone detected. Check that it is properly wired."
|
"audioinput": "No microphone detected. Check that it is properly wired."
|
||||||
},
|
},
|
||||||
"deviceInUse": {
|
|
||||||
"videoinput": "Camera unavailable: it is probably in use by another application or browser tab.",
|
|
||||||
"audioinput": "Microphone unavailable: it is probably in use by another application or browser tab."
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audio settings",
|
"audio": "Audio settings",
|
||||||
"video": "Video settings"
|
"video": "Video settings"
|
||||||
@@ -71,7 +67,6 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Camera is disabled.",
|
"cameraDisabled": "Camera is disabled.",
|
||||||
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
|
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
|
||||||
"cameraInUse": "Your camera is unavailable. It is probably being used by another application or browser tab.",
|
|
||||||
"cameraStarting": "Camera is starting…",
|
"cameraStarting": "Camera is starting…",
|
||||||
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
|
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
|
||||||
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
|
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
|
||||||
|
|||||||
@@ -46,9 +46,6 @@
|
|||||||
"passedHint": "Votre navigateur, vos périphériques et votre réseau sont prêts pour une réunion.",
|
"passedHint": "Votre navigateur, vos périphériques et votre réseau sont prêts pour une réunion.",
|
||||||
"partial": "Test partiel",
|
"partial": "Test partiel",
|
||||||
"partialHint": "Certaines vérifications ont été ignorées. Autorisez l'accès à votre caméra et à votre microphone pour les tester.",
|
"partialHint": "Certaines vérifications ont été ignorées. Autorisez l'accès à votre caméra et à votre microphone pour les tester.",
|
||||||
"warning": "Connexion non optimale",
|
|
||||||
"warningHint": "Attention : vous n'êtes pas dans les conditions optimales pour accéder à l'outil. Vos flux ne transitent pas en UDP (repli sur un relais TURN en TCP ou TLS), ce qui peut dégrader la qualité audio et vidéo.",
|
|
||||||
"warningDocLink": "Documentation technique à destination de votre service informatique",
|
|
||||||
"failed_one": "{{count}} vérification en échec",
|
"failed_one": "{{count}} vérification en échec",
|
||||||
"failed_other": "{{count}} vérifications en échec",
|
"failed_other": "{{count}} vérifications en échec",
|
||||||
"failedHint": "Ouvrez les vérifications en échec pour voir le détail, puis transmettez le rapport à votre service informatique."
|
"failedHint": "Ouvrez les vérifications en échec pour voir le détail, puis transmettez le rapport à votre service informatique."
|
||||||
|
|||||||
@@ -16,10 +16,6 @@
|
|||||||
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||||
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
|
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
|
||||||
},
|
},
|
||||||
"deviceInUse": {
|
|
||||||
"videoinput": "Caméra indisponible : elle est probablement utilisée par une autre application ou un autre onglet.",
|
|
||||||
"audioinput": "Microphone indisponible : il est probablement utilisé par une autre application ou un autre onglet."
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Paramètres audio",
|
"audio": "Paramètres audio",
|
||||||
"video": "Paramètres video"
|
"video": "Paramètres video"
|
||||||
@@ -71,7 +67,6 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "La caméra est désactivée.",
|
"cameraDisabled": "La caméra est désactivée.",
|
||||||
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||||
"cameraInUse": "Votre caméra n'est pas disponible. Elle est probablement utilisée par une autre application ou un autre onglet.",
|
|
||||||
"cameraStarting": "La caméra va démarrer…",
|
"cameraStarting": "La caméra va démarrer…",
|
||||||
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
|
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
|
||||||
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
|
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
|
||||||
|
|||||||
@@ -46,9 +46,6 @@
|
|||||||
"passedHint": "Je browser, apparaten en netwerk zijn klaar voor een vergadering.",
|
"passedHint": "Je browser, apparaten en netwerk zijn klaar voor een vergadering.",
|
||||||
"partial": "Gedeeltelijke test",
|
"partial": "Gedeeltelijke test",
|
||||||
"partialHint": "Sommige controles zijn overgeslagen. Geef toegang tot je camera en microfoon om deze te testen.",
|
"partialHint": "Sommige controles zijn overgeslagen. Geef toegang tot je camera en microfoon om deze te testen.",
|
||||||
"warning": "Verbinding niet optimaal",
|
|
||||||
"warningHint": "Let op: je bevindt je niet in optimale omstandigheden om de tool te gebruiken. Je media loopt niet via UDP (terugval op een TURN-relay via TCP of TLS), wat de audio- en videokwaliteit kan verminderen.",
|
|
||||||
"warningDocLink": "Technische documentatie voor je IT-afdeling",
|
|
||||||
"failed_one": "{{count}} controle mislukt",
|
"failed_one": "{{count}} controle mislukt",
|
||||||
"failed_other": "{{count}} controles mislukt",
|
"failed_other": "{{count}} controles mislukt",
|
||||||
"failedHint": "Open de mislukte controles voor meer details en stuur het rapport door naar je IT-afdeling."
|
"failedHint": "Open de mislukte controles voor meer details en stuur het rapport door naar je IT-afdeling."
|
||||||
|
|||||||
@@ -16,10 +16,6 @@
|
|||||||
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||||
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
|
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
|
||||||
},
|
},
|
||||||
"deviceInUse": {
|
|
||||||
"videoinput": "Camera niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
|
|
||||||
"audioinput": "Microfoon niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad."
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audio-instellingen",
|
"audio": "Audio-instellingen",
|
||||||
"video": "Video-instellingen"
|
"video": "Video-instellingen"
|
||||||
@@ -71,7 +67,6 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||||
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||||
"cameraInUse": "Je camera is niet beschikbaar. Deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
|
|
||||||
"cameraStarting": "Camera wordt ingeschakeld…",
|
"cameraStarting": "Camera wordt ingeschakeld…",
|
||||||
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
||||||
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
||||||
|
|||||||
@@ -1,68 +1,14 @@
|
|||||||
import { proxy } from 'valtio'
|
import { proxy } from 'valtio'
|
||||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
import type { PermissionKind } from './permissions'
|
|
||||||
|
|
||||||
// Device availability (not permission):
|
// Device presence (not permission): enumerateDevices() exposes kinds
|
||||||
// - presence: enumerateDevices() exposes kinds before any grant.
|
// before any grant. Optimistic defaults until the first sync.
|
||||||
// Optimistic defaults until the first sync.
|
export const deviceAvailabilityStore = proxy({
|
||||||
// - in use: the device exists and is allowed, but getUserMedia() failed
|
|
||||||
// because another application or tab is holding it. The id of the
|
|
||||||
// affected device is kept (when known) so that a selection change can
|
|
||||||
// invalidate a flag that no longer concerns the selected device.
|
|
||||||
export const deviceAvailabilityStore = proxy<{
|
|
||||||
hasCamera: boolean
|
|
||||||
hasMicrophone: boolean
|
|
||||||
cameraInUse: boolean
|
|
||||||
microphoneInUse: boolean
|
|
||||||
cameraInUseDeviceId?: string
|
|
||||||
microphoneInUseDeviceId?: string
|
|
||||||
synced: boolean
|
|
||||||
}>({
|
|
||||||
hasCamera: true,
|
hasCamera: true,
|
||||||
hasMicrophone: true,
|
hasMicrophone: true,
|
||||||
cameraInUse: false,
|
|
||||||
microphoneInUse: false,
|
|
||||||
synced: false,
|
synced: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const IN_USE_KEY: Record<PermissionKind, 'cameraInUse' | 'microphoneInUse'> = {
|
|
||||||
camera: 'cameraInUse',
|
|
||||||
microphone: 'microphoneInUse',
|
|
||||||
}
|
|
||||||
|
|
||||||
const IN_USE_DEVICE_KEY: Record<
|
|
||||||
PermissionKind,
|
|
||||||
'cameraInUseDeviceId' | 'microphoneInUseDeviceId'
|
|
||||||
> = {
|
|
||||||
camera: 'cameraInUseDeviceId',
|
|
||||||
microphone: 'microphoneInUseDeviceId',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
|
|
||||||
|
|
||||||
export const noteDeviceInUse = (kind?: PermissionKind, deviceId?: string) => {
|
|
||||||
for (const k of kind ? [kind] : ALL_KINDS) {
|
|
||||||
deviceAvailabilityStore[IN_USE_KEY[k]] = true
|
|
||||||
deviceAvailabilityStore[IN_USE_DEVICE_KEY[k]] = deviceId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clearDeviceInUse = (kind?: PermissionKind) => {
|
|
||||||
for (const k of kind ? [kind] : ALL_KINDS) {
|
|
||||||
deviceAvailabilityStore[IN_USE_KEY[k]] = false
|
|
||||||
deviceAvailabilityStore[IN_USE_DEVICE_KEY[k]] = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clearDeviceInUseForOtherDevice = (
|
|
||||||
kind: PermissionKind,
|
|
||||||
selectedDeviceId?: string
|
|
||||||
) => {
|
|
||||||
const affected = deviceAvailabilityStore[IN_USE_DEVICE_KEY[kind]]
|
|
||||||
if (!affected || !selectedDeviceId || affected === selectedDeviceId) return
|
|
||||||
clearDeviceInUse(kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const syncDeviceAvailability = async (): Promise<void> => {
|
export const syncDeviceAvailability = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.28.0",
|
"version": "1.27.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.28.0",
|
"version": "1.27.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.1",
|
"@html-to/text-cli": "0.6.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.28.0",
|
"version": "1.27.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": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.28.0",
|
"version": "1.27.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.28.0",
|
"version": "1.27.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.28.0",
|
"version": "1.27.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.28.0"
|
version = "1.27.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
"uvicorn>=0.24.0",
|
"uvicorn>=0.24.0",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user