Compare commits

..

7 Commits

Author SHA1 Message Date
lebaudantoine a9c9a36ac7 🐛(frontend) fix joined notification tile no longer rendering properly
The toast is enqueued on `ParticipantConnected`, which livekit-client
fires before the participant's track publications exist. At that
point `getTrackPublication(Camera)` is `undefined`, and the track
ref used by the toast is frozen that way — the toast never
re-subscribes to the participant.

Downstream effects:

* LiveKit's `useIsMuted` initializes to `false` when there is no
  publication, so the tile first paints with
  `data-lk-video-muted="false"`, which the stock CSS maps to
  placeholder `opacity: 0` — a blank box.
* Its effect then subscribes to `mutedObserver`, which defaults to
  `true` for the same situation, triggering a second render and
  only then starting the 200 ms fade-in. That render-effect-render
  transition is the visible lag behind the text.
* Because the track ref never updates, no `VideoTrack` ever mounts.
  When the camera is later subscribed, the placeholder fades out
  again, leaving the tile empty.

Re-derive the track ref inside the toast when the participant's
publications become available, so `useIsMuted` sees the real state
from the start and the tile renders the video (or a stable
placeholder) instead of a blank box.
2026-08-21 22:12:23 +02:00
lebaudantoine 35f2d3da86 🐛(frontend) hoist mute confirmation dialog to VideoConference level
`MuteButton` owned the confirmation dialog state locally. On the
participant tile, the button lives inside `FadeOverlay`, which
conditionally mounts its children based on tile mouse activity
(3 s idle timeout). Once the user moved the pointer onto the
react-aria portal, mouse events on the tile stopped, the idle timer
expired, `FadeOverlay` unmounted `MuteButton`, and the dialog was
destroyed with it — hence the "closes randomly" behavior.

Render the dialog once at the VideoConference level via a shared
`MuteAlertDialogProvider`, so its lifetime is independent from any
button that opens it.
2026-08-21 21:26:47 +02:00
lebaudantoine e204cd7f38 💄(frontend) increase the blur intensity
Bump the blur strength applied to virtual backgrounds so it obscures
the background more effectively.

Requested by users.
2026-08-21 19:48:01 +02:00
lebaudantoine 8d28343e80 🐛(frontend) stop init_virtual_background from firing on blur updates
A regression introduced with the custom virtual background feature
(16daf7b8, March 26): every blur init on Firefox, and every update
call, was going through `init_virtual_background`, which raised an
error because there is no virtual background to initialize in that
code path.

The existing guard was incorrect. Replace it with an early return,
so `init_virtual_background` is only executed when there is actually
a virtual background to initialize.

This does not fix the other virtual background issues that currently
prevent some users from activating their camera; those will be
addressed separately.
2026-08-21 19:14:07 +02:00
lebaudantoine e1eb30d798 📈(frontend) track errors when starting or stopping a recording
Send an analytics event whenever starting or stopping a recording
fails, so we can monitor how often it happens.

It also gives support the context needed to understand what went
wrong when inspecting a user's session.
2026-08-21 19:14:07 +02:00
lebaudantoine 1bd2325ad2 🐛(frontend) use state instead of a ref for MoreControls container
After the recent refactoring, `MoreControls` only renders once. On
that first render, the container ref is `null`, and when it later
gets a reference to the div, the ref update does not trigger a
re-render.

As a result, the additional controls were never showing up.

Switch from a ref to a state to track the container element. State
updates do trigger a re-render, so the controls now show as
expected once the container is available.
2026-08-21 19:14:07 +02:00
lebaudantoine ebaf24c795 🐛(frontend) treat client-initiated connect aborts as events
`connect()` calls can be aborted by a `disconnect()` before the
join completes — typically when the user leaves, refreshes,
navigates away early, or when the component remounts. This is
expected behavior, not a failure.

Stop reporting these as errors. Track them as analytics events
instead, so we can still monitor their volume without polluting
error dashboards.

Volume is relatively low, but keeping visibility helps troubleshoot
users who complain about difficulty connecting to a room, and could
also flag a component that is re-rendering and killing the
connection.
2026-08-21 19:14:04 +02:00
63 changed files with 341 additions and 1434 deletions
-25
View File
@@ -10,27 +10,7 @@ and this project adheres to
### 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) explain camera-in-use failures on the join screen
### Changed
@@ -51,10 +31,6 @@ and this project adheres to
- 🐛(frontend) stop init_virtual_background from firing on blur updates
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
- 🐛(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
@@ -62,7 +38,6 @@ and this project adheres to
- 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
- ✨(summary) add hostname to analytics properties
### Fixed
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.28.0"
version = "1.27.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.28.0"
version = "1.27.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
+1 -46
View File
@@ -5,7 +5,7 @@ from django.http import Http404
from rest_framework import permissions
from ..models import RoleChoices, RoomAccessLevel
from ..models import RoleChoices
from ..services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
@@ -198,48 +198,3 @@ class IsPresentInMeeting(permissions.BasePermission):
return False
except ParticipantsManagementException:
return False
class CanManageLobby(permissions.BasePermission):
"""Grant lobby management (list/accept/deny waiting participants).
- Room admins/owners can always manage the lobby.
- When the room access level is TRUSTED, any authenticated user who is
currently connected to the meeting can manage the lobby. Presence is
verified cache-first (Redis), falling back to the LiveKit API.
Access level is always read fresh from the DB; only presence is cached,
so changing the room to RESTRICTED takes effect immediately.
"""
message = "You are not allowed to manage this room's lobby."
# pylint: disable=too-many-return-statements
def has_object_permission(self, request, view, obj): # noqa: PLR0911
"""Check privileges first, then the trusted-room presence path."""
user = request.user
if not user or not user.is_authenticated:
return False
# Product choice: lobby management is reserved for session-authenticated
# users with a real account, not holders of a LiveKit room token.
if request.auth and hasattr(request.auth, "video"):
return False
if obj.is_administrator_or_owner(user):
return True
if obj.access_level != RoomAccessLevel.TRUSTED:
return False
self.message = "You must be connected to the meeting to manage its lobby."
try:
return ParticipantsManagement().check_if_in_meeting_cached(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
+2 -2
View File
@@ -536,7 +536,7 @@ class RoomViewSet(
methods=["post"],
url_path="enter",
permission_classes=[
permissions.CanManageLobby,
permissions.HasPrivilegesOnRoom,
],
)
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
@@ -574,7 +574,7 @@ class RoomViewSet(
methods=["GET"],
url_path="waiting-participants",
permission_classes=[
permissions.CanManageLobby,
permissions.HasPrivilegesOnRoom,
],
)
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 .presence import PresenceCache
from .room_management import (
RoomManagement,
RoomManagementException,
@@ -100,7 +99,6 @@ class LiveKitEventsService:
"egress_ended": self._handle_egress_ended,
"room_started": self._handle_room_started,
"room_finished": self._handle_room_finished,
"participant_left": self._handle_participant_left,
}
token_verifier = api.TokenVerifier(
@@ -109,7 +107,6 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.presence_cache = PresenceCache()
self.sip_management = SIPManagement()
self.recording_events = RecordingEventsService()
@@ -288,31 +285,9 @@ class LiveKitEventsService:
f"Failed to delete sip dispatch rule for room {room_id}"
) from e
self.presence_cache.clear_room(room_id)
try:
self.lobby_service.clear_room_cache(room_id)
except Exception as e:
raise ActionFailedError(
f"Failed to clear room cache for room {room_id}"
) from e
def _handle_participant_left(self, data):
"""Handle 'participant_left': invalidate the presence cache.
Presence entries are created lazily (only for users who administrate
the lobby of a trusted room), so for most participants this delete is
a no-op DEL on a key that never existed. Eager invalidation shrinks
the window during which a departed participant could still act on a
trusted room's lobby (cache hit until TTL expiry). It is gated behind
`PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be
measured and the behaviour reverted independently of the feature.
When disabled, invalidation relies on `room_finished` and the TTL.
"""
if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT:
return
identity = data.participant.identity
if not identity:
return
self.presence_cache.clear(data.room.name, identity)
+8 -4
View File
@@ -270,7 +270,7 @@ class LobbyService:
"""List all waiting participants for a room."""
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:
return []
@@ -345,9 +345,13 @@ class LobbyService:
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
)
pattern = self._get_cache_key(room_id, "*")
keys = cache.keys(pattern)
if not keys:
return
cache.delete_many(keys)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""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 .lobby import LobbyService
from .presence import PresenceCache
logger = getLogger(__name__)
@@ -73,9 +72,7 @@ class ParticipantsManagement:
@async_to_sync
async def remove(self, room_name: str, identity: str):
"""Remove a participant from a room and clear their lobby/presence cache."""
PresenceCache().clear(room_name, identity)
"""Remove a participant from a room and clear their lobby cache."""
try:
LobbyService().clear_participant_cache(
@@ -159,30 +156,6 @@ class ParticipantsManagement:
finally:
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 def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`.
-52
View File
@@ -1,52 +0,0 @@
"""Presence cache.
Redis-backed memo of "this identity is currently connected to this room".
This module is intentionally a *pure cache store* with no dependency on other
services, so that `participants_management` (which talks to LiveKit) can
import it without creating an import cycle. The composition of "check cache,
fall back to LiveKit" lives in
`ParticipantsManagement.check_if_in_meeting_cached`.
Only positive answers are stored: a sticky negative would lock out someone
who joins right after a miss for the whole TTL. The TTL is a safety net in
case an invalidation webhook is lost.
"""
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from core.utils import CACHE_SCAN_ITERSIZE
class PresenceCache:
"""Store and invalidate (room, identity) presence entries."""
@staticmethod
def _get_cache_key(room_id: UUID | str, identity: str) -> str:
"""Cache key for a (room, identity) presence entry."""
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
"""Return True if a positive presence entry exists in cache."""
return bool(cache.get(self._get_cache_key(room_id, identity)))
def mark_present(self, room_id: UUID | str, identity: str) -> None:
"""Record that `identity` is in `room_id`."""
cache.set(
self._get_cache_key(room_id, identity),
True,
timeout=settings.PRESENCE_CACHE_TIMEOUT,
)
def clear(self, room_id: UUID | str, identity: str) -> None:
"""Forget presence for one participant (e.g. on participant_left)."""
cache.delete(self._get_cache_key(room_id, identity))
def clear_room(self, room_id: UUID | str) -> None:
"""Forget presence for every participant of a room (on room_finished)."""
cache.delete_pattern(
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
)
@@ -389,7 +389,7 @@ def test_allow_participant_to_enter_anonymous():
def test_allow_participant_to_enter_non_owner():
"""Non-privileged users should not be allowed to manage entry requests."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
@@ -522,7 +522,7 @@ def test_list_waiting_participants_anonymous():
def test_list_waiting_participants_non_owner():
"""Non-privileged users should not be allowed to list waiting participants."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory()
user = UserFactory()
client = APIClient()
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_finished.assert_not_called()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_clearing_gated_by_setting(mock_delete, service, settings):
"""PRESENCE_CLEAR_ON_PARTICIPANT_LEFT toggles eager presence invalidation."""
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = "user-sub"
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = False
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_called_once()
@mock.patch("core.services.presence.cache.delete")
def test_participant_left_without_identity_is_ignored(mock_delete, service, settings):
"""No cache operation when the webhook carries no identity."""
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
data = mock.Mock()
data.room.name = "room-name"
data.participant.identity = ""
service._handle_participant_left(data) # pylint: disable=protected-access
mock_delete.assert_not_called()
+10 -11
View File
@@ -24,7 +24,6 @@ from core.services.lobby import (
LobbyParticipantStatus,
LobbyService,
)
from core.services.presence import CACHE_SCAN_ITERSIZE
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -579,14 +578,14 @@ def test_get_participant_parsing_error(
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist."""
mock_cache.iter_keys.return_value = []
mock_cache.keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service.list_waiting_participants(room.id)
assert result == []
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()
@@ -595,7 +594,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
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}
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]["username"] == "test-username"
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])
@@ -629,7 +628,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"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 = {
cache_key1: participant1,
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)
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])
@@ -656,7 +655,7 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
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"}}
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"}
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 = {
cache_key1: corrupted_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
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])
@@ -724,7 +723,7 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"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 = {
cache_key1: participant1,
cache_key2: participant2,
@@ -1,108 +0,0 @@
"""Tests for the presence cache and the cached presence check."""
# pylint: disable=W0212
from unittest import mock
from uuid import uuid4
from django.core.cache import cache
import pytest
from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.presence import PresenceCache
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_hit_skips_livekit(mock_check):
"""A cached positive answer must not call LiveKit."""
room_id, identity = str(uuid4()), "user-sub"
PresenceCache().mark_present(room_id, identity)
assert (
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) is True
)
mock_check.assert_not_called()
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_cache_miss_calls_livekit_and_caches_positive(mock_check):
"""On a miss, LiveKit is asked once and a positive answer is memoized."""
mock_check.return_value = True
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert service.check_if_in_meeting_cached(room_id, identity) is True
assert mock_check.call_count == 1
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_negative_not_cached(mock_check):
"""Negative answers are never memoized."""
mock_check.return_value = False
room_id, identity = str(uuid4()), "user-sub"
service = ParticipantsManagement()
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert service.check_if_in_meeting_cached(room_id, identity) is False
assert mock_check.call_count == 2
@mock.patch(
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
)
def test_presence_errors_propagate_and_cache_nothing(mock_check):
"""LiveKit errors propagate to the caller (which fails closed); nothing cached."""
room_id, identity = str(uuid4()), "user-sub"
for exc in (ParticipantNotFoundException(), ParticipantsManagementException()):
mock_check.side_effect = exc
with pytest.raises(type(exc)):
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity)
assert cache.get(PresenceCache._get_cache_key(room_id, identity)) is None
def test_presence_clear_and_clear_room():
"""clear() removes one entry, clear_room() removes all entries of a room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
presence.mark_present(room_id, "a")
presence.mark_present(room_id, "b")
presence.mark_present(other_room, "a")
presence.clear(room_id, "a")
assert presence.is_marked_present(room_id, "a") is False
assert presence.is_marked_present(room_id, "b") is True
presence.clear_room(room_id)
assert presence.is_marked_present(room_id, "b") is False
assert presence.is_marked_present(other_room, "a") is True
def test_presence_clear_room_scans_in_pages():
"""clear_room removes every match, even across several SCAN pages,
and only within the room."""
room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache()
for i in range(7):
presence.mark_present(room_id, f"user-{i}")
presence.mark_present(other_room, "user-0")
# An itersize smaller than the match count forces delete_pattern to
# page through several SCAN cursors rather than finish in one pass.
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
presence.clear_room(room_id)
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
assert presence.is_marked_present(other_room, "user-0") is True
-3
View File
@@ -512,6 +512,3 @@ def build_telephony_config():
"default_country": country,
"international_phone_number": international,
}
CACHE_SCAN_ITERSIZE = 500
-9
View File
@@ -856,15 +856,6 @@ class Base(Configuration):
)
# 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(
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
)
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.28.0"
version = "1.27.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.28.0"
version = "1.27.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+32 -44
View File
@@ -1,14 +1,14 @@
{
"name": "meet",
"version": "1.28.0",
"version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.28.0",
"version": "1.27.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4",
@@ -16,10 +16,10 @@
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@pandacss/preset-panda": "1.12.0",
"@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3",
@@ -27,9 +27,9 @@
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.21.0",
"posthog-js": "1.409.5",
"posthog-js": "1.404.1",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
@@ -767,9 +767,9 @@
"license": "MIT"
},
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.3.0.tgz",
"integrity": "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==",
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.2.6.tgz",
"integrity": "sha512-D0Z8peFkRGYACvmWp8Sl8YPGYLBEJbzqapugtsMEYSUUtpQDiZVDHjk9s7bqB4hSCRYCrKuW6V1V+mnHybVnPw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
@@ -1642,21 +1642,21 @@
}
},
"node_modules/@pandacss/preset-panda": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.12.0.tgz",
"integrity": "sha512-jhzhAmtGY34b1FlMJGEAh/IkfU4WyTNT2mpJfcBUPBi1rZLHSTcs0aqedRmMdwM4tTT43XFOXMiazbztKME60A==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.3.tgz",
"integrity": "sha512-IHeRQyIYeGKpQl78A/oI9fGg8MHQiOX/HttH+iQNaRU/MbGN6JsPe/N0ekQC6QY8iiKtLhqR+SRc455SN6JVWw==",
"license": "MIT",
"dependencies": {
"@pandacss/types": "1.12.0"
"@pandacss/types": "1.11.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.12.0.tgz",
"integrity": "sha512-PtlEIOBEV3RzWffeu1zH+/YFmP+fn24M63STGEbgTwjEBMhyo/FfT2sfZGB8oWAbWUBKBp69OMg25E+/rY3klQ==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.11.3.tgz",
"integrity": "sha512-tUbxa3WzoCuulP6kcMn7f0uNgLBvu81JAg4sfoUpz6PZE36VHA3k9n6ROEwziTbr7vqgeCj5DYQCUldrVNbEGQ==",
"license": "MIT",
"engines": {
"node": ">=20"
@@ -1719,16 +1719,6 @@
"dev": true,
"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": {
"version": "1.48.3",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
@@ -2424,9 +2414,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2445,12 +2435,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.4"
"@tanstack/query-core": "5.101.1"
},
"funding": {
"type": "github",
@@ -6985,10 +6975,9 @@
}
},
"node_modules/i18next-resources-to-backend": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.3.tgz",
"integrity": "sha512-8Y/LLAm5fqZc2ckQxtTWbu75ndjNLzF7mcYl6rhc4g+WBopIsG3YhdYqF5qhJy64tlYhlU2KSSnEgW3gGHLqwg==",
"license": "MIT",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz",
"integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
@@ -9134,14 +9123,13 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.409.5",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.409.5.tgz",
"integrity": "sha512-s1iJz+vq0YAluUD4OwLjUFIJt/9pqiiB6MITvHWIS16a7pqTJqbSLXsd5/8kJcIgfrOSZHe+mdYAXLYcJCS4LQ==",
"version": "1.404.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
"integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
"license": "(Apache-2.0 AND MIT)",
"dependencies": {
"@posthog/browser-common": "^0.3.1",
"@posthog/core": "^1.46.1",
"@posthog/types": "^1.399.0",
"@posthog/core": "^1.43.1",
"@posthog/types": "^1.397.0",
"core-js": "^3.49.0",
"dompurify": "^3.3.2",
"fflate": "^0.4.8",
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.28.0",
"version": "1.27.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -15,7 +15,7 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4",
@@ -23,10 +23,10 @@
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@pandacss/preset-panda": "1.12.0",
"@pandacss/preset-panda": "1.11.3",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-query": "5.101.1",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3",
@@ -34,9 +34,9 @@
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.21.0",
"posthog-js": "1.409.5",
"posthog-js": "1.404.1",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
@@ -2,12 +2,6 @@ import type { CaptureResult } from 'posthog-js'
const IGNORED_EXCEPTION_PATTERNS = [
/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 =>
@@ -137,7 +137,6 @@ export const captureMediaEvent = async (
| 'media-device-topology'
| 'media-device-success'
| 'device-not-found'
| 'device-in-use'
| 'permissions-denied'
| 'screen-share-permission-denied'
| 'silent-mic-detected'
@@ -42,23 +42,6 @@ export type IceCandidateReport = {
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_HEIGHT = 180
const PROBE_FPS = 15
@@ -2,33 +2,18 @@ import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { ProgressBar } from 'react-aria-components'
import { css, cx } from '@/styled-system/css'
import { A } from '@/primitives'
import type { ConnectionTestStats } from '../types'
import { statusSquareClass } from './stepAppearance'
/**
* 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 = 'idle' | 'running' | 'passed' | 'partial' | 'failed'
type SummaryState =
| 'idle'
| 'running'
| 'passed'
| 'partial'
| 'failed'
| 'warning'
/** Only a failure or a degraded route earns a colour: everything else stays near-black. */
/** Only a failure earns a colour: everything else stays near-black. */
const stateColorClass: Record<SummaryState, string> = {
idle: css({ color: 'greyscale.1000' }),
running: css({ color: 'greyscale.1000' }),
passed: css({ color: 'greyscale.1000' }),
partial: css({ color: 'greyscale.1000' }),
failed: css({ color: 'danger.600' }),
warning: css({ color: 'warning' }),
}
const cardClass = css({
@@ -191,28 +176,20 @@ const Counter = ({
export const ConnectionTestSummary = ({
stats,
isRunning,
routeWarning = false,
children,
}: {
stats: ConnectionTestStats
isRunning: boolean
/** The selected ICE route is usable but not UDP (e.g. TURN over TCP/TLS). */
routeWarning?: boolean
children?: ReactNode
}) => {
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
? 'running'
: !stats.hasStarted
? 'idle'
: stats.failed > 0
? 'failed'
: routeWarning
? 'warning'
: stats.skipped > 0
? 'partial'
: 'passed'
@@ -229,22 +206,7 @@ export const ConnectionTestSummary = ({
: t(`summary.${state}`)}
</p>
<p className={hintClass}>
{t(`summary.${state}Hint`)}
{state === 'warning' && (
<>
{' '}
<A
href={NETWORK_REQUIREMENTS_DOC_URL}
target="_blank"
rel="noreferrer"
size="sm"
>
{t('summary.warningDocLink')}
</A>
</>
)}
</p>
<p className={hintClass}>{t(`summary.${state}Hint`)}</p>
</div>
{stats.hasStarted && (
@@ -53,9 +53,6 @@ const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
summary: info.description,
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[]) => {
@@ -13,7 +13,6 @@ import { css } from '@/styled-system/css'
import { Center, VStack } from '@/styled-system/jsx'
import { Permissions } from '@/features/rooms/components/Permissions'
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
import { isSuboptimalRoute } from '../checks/selectedCandidate'
import { ConnectionTestStepRow } from '../components/ConnectionTestStepRow'
import { ConnectionTestSummary } from '../components/ConnectionTestSummary'
import { CONNECTION_TEST_GROUPS, summarizeSteps } from '../types'
@@ -71,13 +70,6 @@ const ConnectionTest = () => {
() => new Map(steps.map((step) => [step.id, step] as const)),
[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 =
stepsById.get('publishVideo')?.status === 'running'
@@ -107,11 +99,7 @@ const ConnectionTest = () => {
<CenteredContent withBackButton>
<Center>
<VStack gap="1.5rem" maxWidth="40rem" width="100%">
<ConnectionTestSummary
stats={stats}
isRunning={isRunning}
routeWarning={routeWarning}
>
<ConnectionTestSummary stats={stats} isRunning={isRunning}>
{isRunning ? (
// A disabled "run" button while the test runs is dead weight:
// cancelling is the only thing left to do.
@@ -13,7 +13,7 @@ const controlBarRegion = cva({
mobile: {
true: {
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 { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useEnterRoom } from '../api/enterRoom'
import {
useListWaitingParticipants,
type WaitingParticipant,
} from '../../participants/api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { reportError } from '@/features/analytics/telemetry'
export const POLL_INTERVAL_MS = 1000
export const useWaitingParticipants = () => {
const [listEnabled, setListEnabled] = useState(true)
const roomData = useRoomData()
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 } =
useListWaitingParticipants(roomId, {
retry: false,
enabled: false,
enabled: listEnabled && isAdminOrOwner,
refetchInterval: POLL_INTERVAL_MS,
refetchIntervalInBackground: true,
})
const waitingParticipants = useMemo(
() => (canManageLobby ? waitingData?.participants || [] : []),
[waitingData, canManageLobby]
() => waitingData?.participants || [],
[waitingData]
)
useEffect(() => {
if (!waitingParticipants.length) setListEnabled(false)
}, [waitingParticipants])
const { mutateAsync: enterRoom } = useEnterRoom()
const handleParticipantEntry = async (
@@ -44,6 +74,8 @@ export const useWaitingParticipants = () => {
allowEntry: boolean
): Promise<void> => {
try {
setListEnabled(false)
await Promise.all(
waitingParticipants.map((participant) =>
enterRoom({
@@ -57,6 +89,7 @@ export const useWaitingParticipants = () => {
await refetchWaiting()
} catch (e) {
reportError('generic_failure', e)
setListEnabled(true)
}
}
@@ -1,3 +1,4 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
@@ -6,8 +7,6 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { REACTIONS_TOOLBAR_ID } from '../constants'
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
import { layoutStore } from '@/stores/layout'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const focusReactionsToolbar = () => {
document
@@ -18,44 +17,35 @@ const focusReactionsToolbar = () => {
export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
/* eslint-disable react-refresh/only-export-components */
export const reactionShortcutHandler = () => {
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { isOpen, toggle } = useReactionsToolbar()
const handleShortcut = useCallback(() => {
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 { isOpen, toggle } = useReactionsToolbar()
}, [])
useRegisterKeyboardShortcut({
id: 'reaction',
handler: reactionShortcutHandler,
handler: handleShortcut,
})
return (
<ToggleButton
{...props}
id={REACTIONS_TOGGLE_ID}
data-attr="reactions-toggle"
square
variant={variant}
variant="primaryDark"
aria-label={t('button')}
aria-expanded={isOpen}
tooltip={t('button')}
isSelected={isOpen}
onChange={toggle}
onPress={onPress}
>
<RiEmotionLine />
</ToggleButton>
@@ -14,7 +14,6 @@ import {
type RoomOptions,
VideoPresets,
} from 'livekit-client'
import { getMediaDeviceFailure } from '@/features/rooms/livekit/utils/mediaPermissions'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
@@ -43,7 +42,6 @@ import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user'
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
export const Conference = ({
roomId,
@@ -116,7 +114,6 @@ export const Conference = ({
},
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
...VOICE_AUDIO_CONSTRAINTS,
},
audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined,
@@ -234,7 +231,7 @@ export const Conference = ({
backgroundColor: 'primaryDark.50 !important',
})}
onError={(e) => {
const failure = getMediaDeviceFailure(e)
const failure = MediaDeviceFailure.getFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return
// connect() was aborted by a disconnect() before the join completed
@@ -28,7 +28,6 @@ import {
userChoicesStore,
} from '@/stores/userChoices'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse'
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
import { SilentMicDetector } from './SilentMicDetector'
@@ -219,14 +218,12 @@ const switchTrackDevice =
function getPreviewMessages({
cameraFound,
cameraDenied,
cameraInUse,
micDenied,
videoEnabled,
videoStarted,
}: {
cameraFound: boolean
cameraDenied: boolean
cameraInUse: boolean
micDenied: boolean
videoEnabled: boolean
videoStarted: boolean
@@ -238,9 +235,6 @@ function getPreviewMessages({
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
return { hint: key, permissionsButtonLabel: key }
}
if (cameraInUse) {
return { hint: 'cameraInUse', permissionsButtonLabel: null }
}
if (!videoEnabled) {
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
}
@@ -334,20 +328,18 @@ const VideoPreview = ({
const cameraDenied = useCannotUseDevice('videoinput')
const micDenied = useCannotUseDevice('audioinput')
const cameraMissing = useDeviceMissing('videoinput')
const cameraInUse = useDeviceInUse('videoinput')
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
const { hint, permissionsButtonLabel } = getPreviewMessages({
cameraFound: !cameraMissing,
cameraDenied,
cameraInUse,
micDenied,
videoEnabled,
videoStarted,
})
const isError = cameraMissing || cameraDenied || cameraInUse
const isError = cameraMissing || cameraDenied
return (
<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',
borderRadius: '0.25rem',
boxShadow: '',
width: '100%',
maxWidth: '380px',
minWidth: '380px',
minHeight: '196px',
},
})
@@ -38,11 +37,8 @@ const ratingButtonRecipe = cva({
color: 'initial',
border: 'none',
borderRadius: 0,
padding: { base: '0.5rem 0.25rem', xsm: '0.5rem 0.85rem' },
padding: '0.5rem 0.85rem',
flexGrow: '1',
flexBasis: 0,
minWidth: 0,
textAlign: 'center',
cursor: 'pointer',
},
variants: {
@@ -103,9 +99,7 @@ const OpenFeedback = ({
return (
<Card>
<H lvl={3} centered>
{t('question')}
</H>
<H lvl={3}>{t('question')}</H>
<TextArea
id="feedbackInput"
name="feedback"
@@ -174,9 +168,7 @@ const RateQuality = ({
return (
<Card>
<H lvl={3} centered>
{t('question')}
</H>
<H lvl={3}>{t('question')}</H>
<Bar>
{[...Array(maxRating)].map((_, index) => (
<RACButton
@@ -236,9 +228,7 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
}}
>
<VStack gap={0}>
<H lvl={3} centered>
{t('heading')}
</H>
<H lvl={3}>{t('heading')}</H>
<Text as="p" variant="paragraph" centered>
{t('body')}
</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 { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { Stack } from '@/styled-system/jsx'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
@@ -134,11 +134,7 @@ export const IsIdleDisconnectModal = () => {
</H>
<Description />
<Settings />
<Stack
direction={{ base: 'column', xsm: 'row' }}
align={{ base: 'stretch', xsm: 'center' }}
marginTop="2rem"
>
<HStack marginTop="2rem">
<Button
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
@@ -152,7 +148,7 @@ export const IsIdleDisconnectModal = () => {
<Button onPress={close} size="sm" variant="primary">
{t('stayButton')}
</Button>
</Stack>
</HStack>
</div>
)
}}
@@ -5,18 +5,16 @@ import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
type PermissionNeededButtonProps = {
tooltip?: string | null
onPress?: (() => void) | null
ariaLabel?: string
tooltip?: string
onPress?: () => void
}
export const PermissionNeededButton = ({
tooltip,
onPress,
ariaLabel,
}: PermissionNeededButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
const label = tooltip === undefined ? t('tooltip') : tooltip
const label = tooltip ?? t('tooltip')
return (
<div
className={css({
@@ -28,13 +26,9 @@ export const PermissionNeededButton = ({
})}
>
<Button
aria-label={ariaLabel ?? label ?? t('ariaLabel')}
tooltip={label ?? undefined}
onPress={
onPress === undefined
? () => openPermissionsDialog()
: (onPress ?? undefined)
}
aria-label={tooltip ? label : t('ariaLabel')}
tooltip={label}
onPress={onPress ?? (() => openPermissionsDialog())}
variant="permission"
>
<div
@@ -20,10 +20,8 @@ import { openPermissionsDialog } from '@/stores/permissions'
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
import { useSnapshot } from 'valtio'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { requestDevicePermission } from '../../../utils/mediaPermissions'
import { userChoicesStore } from '@/stores/userChoices'
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import type {
@@ -99,51 +97,38 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind)
const deviceInUse = useDeviceInUse(kind)
const explainDeviceInUse = deviceInUse && context === 'room'
const { status: silentMicStatus } = useSnapshot(silentMicStore)
const silentMicWarning =
kind === 'audioinput' &&
silentMicStatus === 'silent' &&
!cannotUseDevice &&
!deviceMissing &&
!deviceInUse
!deviceMissing
const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
const isRequestingPermission = useRef(false)
const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
const mediaPath = context === 'join' ? 'join_preview' : 'room'
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
const onPress = async () => {
if (!enabled && deviceMissing) {
setAlertError(MediaDeviceFailure.NotFound)
setShowDeviceNotFound(true)
return
}
if (!cannotUseDevice && !deviceInUse) {
if (!cannotUseDevice) {
toggle()
return
}
if (isRequestingPermission.current) return
isRequestingPermission.current = true
try {
const selectedDeviceId = cannotUseDevice
? undefined
: kind === 'videoinput'
? userChoicesStore.videoDeviceId
: userChoicesStore.audioDeviceId
const acquired = await requestDevicePermission(
const granted = await requestDevicePermission(
kind,
mediaPath,
selectedDeviceId || undefined
context === 'join' ? 'join_preview' : 'room'
)
if (acquired) {
if (granted) {
toggle()
} else if (cannotUseDevice) {
} else {
openPermissionsDialog(kind)
} else if (explainDeviceInUse) {
setAlertError(MediaDeviceFailure.DeviceInUse)
}
} finally {
isRequestingPermission.current = false
@@ -194,34 +179,13 @@ export const ToggleDevice = <T extends ToggleSource>({
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 (
<div style={{ position: 'relative' }}>
{(cannotUseDevice || deviceMissing) && (
<PermissionNeededButton
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
onPress={
deviceMissing
? () => setAlertError(MediaDeviceFailure.NotFound)
: undefined
}
/>
)}
{deviceInUse && (
<PermissionNeededButton
tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : null}
ariaLabel={t(`deviceInUse.${kind}`)}
onPress={
explainDeviceInUse
? () => setAlertError(MediaDeviceFailure.DeviceInUse)
: null
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
}
/>
)}
@@ -240,16 +204,22 @@ export const ToggleDevice = <T extends ToggleSource>({
shySelected
onPress={onPress}
aria-label={toggleLabel}
tooltip={toggleTooltip}
tooltip={
deviceMissing
? t(`deviceNotFound.${kind}`)
: cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
{...computedToggleButtonProps}
{...overrideToggleButtonProps}
>
<Icon />
</ToggleButton>
<MediaDeviceErrorAlert
error={alertError}
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
kind={kind}
onClose={() => setAlertError(null)}
onClose={() => setShowDeviceNotFound(false)}
/>
</div>
)
@@ -10,18 +10,10 @@ import {
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
const SPEAKING_DETECTION_DELAY = 3000
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
export const HandToggle = ({
variant = 'primaryDark',
onPress,
...props
}: Props) => {
export const HandToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
const room = useRoomContext()
@@ -82,16 +74,12 @@ export const HandToggle = ({
})}
>
<ToggleButton
{...props}
square
variant={variant}
variant="primaryDark"
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={(e) => {
handleToggle()
onPress?.(e)
}}
onPress={handleToggle}
data-attr={`controls-hand-${tooltipLabel}`}
>
<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'
import { BackgroundProcessorFactory } from '../components/blur'
import {
classifyPermissionError,
isLikelySystemNotFound,
isSystemPermissionError,
noteGumSuccess,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
type PermissionKind,
} from '@/stores/permissions'
import {
getMediaDeviceFailure,
noteDeviceReady,
onMediaPermissionError,
} from '../utils/mediaPermissions'
import { VOICE_AUDIO_CONSTRAINTS } from '../utils/constants'
import { getOS } from '@/utils/os'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import {
saveAudioInputDeviceId,
saveAudioInputEnabled,
@@ -28,6 +29,74 @@ import {
} from '@/stores/userChoices'
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.
const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false)
@@ -35,6 +104,24 @@ const disableVideo = () => saveVideoInputEnabled(false)
const stopAll = (stream: MediaStream) =>
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 = {
audioReady: boolean
videoReady: boolean
@@ -71,11 +158,11 @@ function useWarmupPermissions(): WarmupState {
video: true,
})
)
noteDeviceReady()
noteGumSuccess()
bothReady()
} catch (error) {
if (
getMediaDeviceFailure(error as Error) ===
MediaDeviceFailure.getFailure(error as Error) ===
MediaDeviceFailure.PermissionDenied &&
!isSystemPermissionError(error)
) {
@@ -93,7 +180,7 @@ function useWarmupPermissions(): WarmupState {
.getUserMedia({ audio: true })
.then((stream) => {
stopAll(stream)
noteDeviceReady('microphone')
noteGumSuccess('microphone')
})
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
.finally(() =>
@@ -103,7 +190,7 @@ function useWarmupPermissions(): WarmupState {
.getUserMedia({ video: true })
.then((stream) => {
stopAll(stream)
noteDeviceReady('camera')
noteGumSuccess('camera')
})
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
.finally(() =>
@@ -122,14 +209,12 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
enabled,
create,
permissionKind,
deviceId,
onFailure,
}: {
ready: boolean
enabled: boolean
create: () => Promise<T>
permissionKind: PermissionKind
deviceId?: string
onFailure: () => void
}): T | null {
const [track, setTrack] = useState<T | null>(null)
@@ -142,27 +227,21 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
let cancelled = false
create()
.then((newTrack) => {
noteGumSuccess(permissionKind)
if (cancelled) {
newTrack.stop()
return
}
noteDeviceReady(permissionKind)
setTrack(newTrack)
})
.catch((error) => {
if (cancelled) return
onMediaPermissionError(
error as Error,
permissionKind,
'join_preview',
deviceId
)
onMediaPermissionError(error as Error, permissionKind)
onFailure()
})
return () => {
cancelled = true
}
}, [ready, enabled, track, create, permissionKind, deviceId, onFailure])
}, [ready, enabled, track, create, permissionKind, onFailure])
// Release on toggle-off so the LED turns off.
useEffect(() => {
@@ -236,7 +315,6 @@ export function useJoinTracks(): {
enabled: audioEnabled,
create: createAudio,
permissionKind: 'microphone',
deviceId: audioDeviceId,
onFailure: disableAudio,
})
@@ -245,7 +323,6 @@ export function useJoinTracks(): {
enabled: videoEnabled,
create: createVideo,
permissionKind: 'camera',
deviceId: videoDeviceId,
onFailure: disableVideo,
})
@@ -1,11 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import {
type LocalTrackPublication,
MediaDeviceFailure,
RoomEvent,
Track,
} from 'livekit-client'
import { MediaDeviceFailure, RoomEvent } from 'livekit-client'
import {
PERMISSION_BY_DEVICE_KIND,
type PermissionDeniedScope,
@@ -15,13 +10,8 @@ import {
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
} from '@/stores/permissions'
import {
clearDeviceInUse,
noteDeviceInUse,
syncDeviceAvailability,
} from '@/stores/deviceAvailability'
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
import { captureMediaEvent } from '@/features/analytics/telemetry'
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
import { getOS } from '@/utils/os'
type MediaDeviceAlert = {
@@ -31,11 +21,6 @@ type MediaDeviceAlert = {
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 = (
scope: PermissionDeniedScope,
kind?: PermissionKind
@@ -64,7 +49,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
useEffect(() => {
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
const failure = getMediaDeviceFailure(error)
const failure = MediaDeviceFailure.getFailure(error)
if (!failure) return
if (failure != MediaDeviceFailure.Other) {
void captureMediaEvent('media-device-error', {
@@ -78,7 +63,6 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
switch (failure) {
case MediaDeviceFailure.DeviceInUse:
if (permissionKind) noteDeviceInUse(permissionKind)
setAlert({ error: failure, kind })
break
case MediaDeviceFailure.NotFound:
@@ -108,16 +92,9 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
break
}
}
const onTrackPublished = (publication: LocalTrackPublication) => {
const permissionKind = PERMISSION_BY_SOURCE[publication.source]
if (permissionKind) clearDeviceInUse(permissionKind)
}
room.on(RoomEvent.MediaDevicesError, onDeviceError)
room.on(RoomEvent.LocalTrackPublished, onTrackPublished)
return () => {
room.off(RoomEvent.MediaDevicesError, onDeviceError)
room.off(RoomEvent.LocalTrackPublished, onTrackPublished)
clearDeviceInUse()
}
}, [room])
@@ -1,7 +1,7 @@
import { supportsScreenSharing } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import type { ControlBarAuxProps } from './ControlBar'
import React, { useLayoutEffect, useRef, useState } from 'react'
import React from 'react'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { Track } from 'livekit-client'
@@ -26,26 +26,7 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { openSettingsDialog } from '@/stores/settings'
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
import {
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,
})
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
export function MobileControlBar({
onDeviceError,
@@ -55,72 +36,19 @@ export function MobileControlBar({
const browserSupportsScreenSharing = supportsScreenSharing()
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 closeMenu = () => setIsMenuOpened(false)
return (
<>
<div
className={css({
width: '100vw',
padding: '1.125rem',
})}
>
<div
ref={containerRef}
className={css({
width: '100%',
display: 'flex',
padding: '1.125rem',
justifyContent: 'center',
})}
>
<ControlBarRegion mobile>
<div ref={barRef} className={measuredRow}>
<LeaveButton />
<AudioDevicesControl
onDeviceError={(error) =>
@@ -134,13 +62,8 @@ export function MobileControlBar({
}
hideMenu={true}
/>
{/* Unmounted when empty so it doesn't leave a stray gap. */}
{!hideReactions && (
<div ref={collapsibleRef} className={measuredRow}>
<ReactionsToggle />
{!hideHand && <HandToggle />}
</div>
)}
<HandToggle />
<Button
id="room-options-trigger"
square
@@ -151,11 +74,12 @@ export function MobileControlBar({
>
<RiMore2Line />
</Button>
</div>
</ControlBarRegion>
</div>
</div>
<ResponsiveMenu isOpened={isMenuOpened} onClosed={closeMenu}>
<ResponsiveMenu
isOpened={isMenuOpened}
onClosed={() => setIsMenuOpened(false)}
>
<div
className={css({
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 && (
<ScreenShareToggle
onDeviceError={(error) =>
@@ -195,16 +105,25 @@ export function MobileControlBar({
}
variant="primaryTextDark"
description={true}
onPress={closeMenu}
onPress={() => setIsMenuOpened(false)}
/>
)}
<ChatToggle description={true} onPress={closeMenu} />
<ParticipantsToggle description={true} onPress={closeMenu} />
<ToolsToggle description={true} onPress={closeMenu} />
<ChatToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<ParticipantsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<ToolsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<Button
onPress={() => {
toggleEffects()
closeMenu()
setIsMenuOpened(false)
}}
variant="primaryTextDark"
aria-label={t('options.items.effects')}
@@ -221,7 +140,7 @@ export function MobileControlBar({
aria-label={t('options.items.feedback')}
description={true}
target="_blank"
onPress={closeMenu}
onPress={() => setIsMenuOpened(false)}
>
<RiMegaphoneLine size={20} />
</LinkButton>
@@ -229,7 +148,7 @@ export function MobileControlBar({
<Button
onPress={() => {
openSettingsDialog()
closeMenu()
setIsMenuOpened(false)
}}
variant="primaryTextDark"
aria-label={t('options.items.settings')}
@@ -238,7 +157,7 @@ export function MobileControlBar({
>
<RiSettings3Line size={20} />
</Button>
<CameraSwitchButton onPress={closeMenu} />
<CameraSwitchButton onPress={() => setIsMenuOpened(false)} />
</div>
</div>
</ResponsiveMenu>
@@ -1,6 +1,5 @@
import { isWeb } from '@livekit/components-core'
import { MediaDeviceFailure, Track } from 'livekit-client'
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
import { Track } from 'livekit-client'
import React, { useState } from 'react'
import {
ConnectionStateToast,
@@ -31,7 +30,6 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
/**
* @public
@@ -102,11 +100,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
return
}
}
if (getMediaDeviceFailure(error) !== MediaDeviceFailure.Other) {
return
}
reportError('device_switch_failure', error, {
at: 'ControlBar.onDeviceError',
source,
@@ -121,7 +114,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<RoomSilentMicDetector />
<MediaStateObserver />
<ChatProvider />
<LobbyProvider />
<VideoResolutionSubscription />
<div
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 { Button } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { Center, Stack, styled, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter'
import { useMemo } from 'react'
@@ -15,18 +14,14 @@ const Heading = styled('h1', {
fontStyle: 'normal',
fontStretch: 'normal',
fontOpticalSizing: 'auto',
fontSize: { base: '1.75rem', xsm: '2.3rem' },
lineHeight: { base: '2.125rem', xsm: '2.5rem' },
fontSize: '2.3rem',
lineHeight: '2.5rem',
letterSpacing: '0',
paddingBottom: { base: '1.5rem', xsm: '2rem' },
paddingBottom: '2rem',
textAlign: 'center',
},
})
const buttonClass = css({
width: { base: '100%', xsm: 'auto' },
})
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
@@ -59,31 +54,19 @@ const FeedbackRoute = () => {
return (
<Screen layout="centered" footer={false}>
<Center width="100%">
<VStack width="100%" paddingX="1rem">
<Center>
<VStack>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<Stack
direction={{ base: 'column', xsm: 'row' }}
width={{ base: '100%', xsm: 'auto' }}
maxWidth="380px"
>
<HStack>
{showBackButton && (
<Button
variant="secondary"
className={buttonClass}
onPress={() => window.history.back()}
>
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
)}
<Button
variant="primary"
className={buttonClass}
onPress={() => setLocation('/')}
>
<Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')}
</Button>
</Stack>
</HStack>
<Rating metadata={metadata} />
</VStack>
</Center>
@@ -16,7 +16,6 @@ import {
import { useConfig } from '@/api/useConfig.ts'
import { LogLevel, setLogLevel } from 'livekit-client'
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
const BaseRoom = ({ children }: { children: ReactNode }) => {
@@ -50,7 +49,6 @@ const Room = () => {
useKeyboardShortcuts()
useWatchDeviceAvailability()
useWatchDeviceReleased()
const clearRouterState = () => {
if (window?.history?.state) {
@@ -46,9 +46,6 @@
"passedHint": "Ihr Browser, Ihre Geräte und Ihr Netzwerk sind für eine Besprechung bereit.",
"partial": "Teilweiser Test",
"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_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."
-5
View File
@@ -16,10 +16,6 @@
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie 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": {
"audio": "Audioeinstellungen",
"video": "Videoeinstellungen"
@@ -71,7 +67,6 @@
},
"cameraDisabled": "Kamera ist deaktiviert.",
"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…",
"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?",
@@ -46,9 +46,6 @@
"passedHint": "Your browser, your devices and your network are ready for a meeting.",
"partial": "Partially tested",
"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_other": "{{count}} checks failed",
"failedHint": "Open the failed checks below for details, then send the report to your IT department."
-5
View File
@@ -16,10 +16,6 @@
"videoinput": "No camera 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": {
"audio": "Audio settings",
"video": "Video settings"
@@ -71,7 +67,6 @@
},
"cameraDisabled": "Camera is disabled.",
"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…",
"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?",
@@ -46,9 +46,6 @@
"passedHint": "Votre navigateur, vos périphériques et votre réseau sont prêts pour une réunion.",
"partial": "Test partiel",
"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_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."
-5
View File
@@ -16,10 +16,6 @@
"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é."
},
"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": {
"audio": "Paramètres audio",
"video": "Paramètres video"
@@ -71,7 +67,6 @@
},
"cameraDisabled": "La caméra est désactivé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…",
"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 ?",
@@ -46,9 +46,6 @@
"passedHint": "Je browser, apparaten en netwerk zijn klaar voor een vergadering.",
"partial": "Gedeeltelijke test",
"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_other": "{{count}} controles mislukt",
"failedHint": "Open de mislukte controles voor meer details en stuur het rapport door naar je IT-afdeling."
-5
View File
@@ -16,10 +16,6 @@
"videoinput": "Geen camera 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": {
"audio": "Audio-instellingen",
"video": "Video-instellingen"
@@ -71,7 +67,6 @@
},
"cameraDisabled": "Camera is uitgeschakeld.",
"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…",
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
+3 -57
View File
@@ -1,68 +1,14 @@
import { proxy } from 'valtio'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import type { PermissionKind } from './permissions'
// Device availability (not permission):
// - presence: enumerateDevices() exposes kinds before any grant.
// Optimistic defaults until the first sync.
// - 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
}>({
// Device presence (not permission): enumerateDevices() exposes kinds
// before any grant. Optimistic defaults until the first sync.
export const deviceAvailabilityStore = proxy({
hasCamera: true,
hasMicrophone: true,
cameraInUse: false,
microphoneInUse: 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> => {
try {
const devices = await navigator.mediaDevices.enumerateDevices()
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.28.0",
"version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.28.0",
"version": "1.27.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.28.0",
"version": "1.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.28.0",
"version": "1.27.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.28.0",
"version": "1.27.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.28.0"
version = "1.27.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+1 -8
View File
@@ -1,10 +1,9 @@
"""Analytics classes."""
import json
import socket
import time
from collections import Counter
from functools import cached_property, lru_cache
from functools import lru_cache
from urllib.parse import urlsplit, urlunsplit
import redis
@@ -34,10 +33,6 @@ class Analytics:
logger.info("Initialize analytics client")
self._client = Posthog(settings.posthog_api_key, settings.posthog_api_host)
@cached_property
def _hostname(self):
return socket.gethostname()
@property
def is_disabled(self):
"""Check if analytics client is disabled or not configured."""
@@ -48,8 +43,6 @@ class Analytics:
if self.is_disabled:
return
properties = {**(properties or {}), "_hostname": self._hostname}
try:
self._client.capture(
event_name, distinct_id=distinct_id, properties=properties