mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 20:27:01 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 047a4c9f3f | |||
| 6c4f0632b8 | |||
| ff7a1a4f33 | |||
| 7d1ce5f215 | |||
| 22ab89994b | |||
| e1a28f315d | |||
| dffcb83fff | |||
| c838229ec9 | |||
| ab40ec365d | |||
| 199c0297d4 | |||
| 089db20a2e | |||
| f0c08bea92 | |||
| 03e90b6178 | |||
| c53a2f8af4 | |||
| 7461cd28ce | |||
| 5723f29cef | |||
| 68a5e84f5d | |||
| b84ee74ee2 | |||
| 0dd2478c3e | |||
| 8f27b89d21 | |||
| b780d2845a | |||
| 751d029ac9 | |||
| aaa51a4457 | |||
| c8a3ef6f61 | |||
| 5d50671b3c | |||
| 8615bf879c | |||
| 186d16c46f | |||
| fb3ee56702 | |||
| 48c0cb320e | |||
| d810c9e0de | |||
| 134d9a188f | |||
| ea7188059d | |||
| b8958e6e87 | |||
| 23bb3c39d0 | |||
| e0ff28ed48 | |||
| 61e8b597dc | |||
| f3626a2dc6 | |||
| 41e937c1f1 | |||
| d988c72208 | |||
| b96d591db3 | |||
| bffc51ac4c | |||
| 22a1713c60 | |||
| c23f449520 | |||
| 0536896373 | |||
| f49c61d9bf | |||
| b593516802 | |||
| 1328098c45 | |||
| 58205f81d4 | |||
| b7892431be | |||
| 00a2bd9558 | |||
| bc003f928e | |||
| d756825fd7 | |||
| b01a47bfd7 | |||
| 06e73d7a5e |
@@ -8,6 +8,49 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.26.0] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- 📈(frontend) capture media diagnostics on media errors
|
||||
- ✨(frontend) add an audio gauge to the microphone select menu
|
||||
- ✨(frontend) add a sound tester to the output select menu
|
||||
- ✨(frontend) prompt for permissions when toggling a denied device
|
||||
- ⚗️(frontend) capture console.error in PostHog
|
||||
- 📈(frontend) snapshot media devices on the happy path
|
||||
- 🚸(frontend) guide users when the OS blocks browser media access
|
||||
- ✨(frontend) add a silent-microphone watcher on join and room screens
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) encapsulate error tracking behind a telemetry module
|
||||
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
|
||||
- 🔧(frontend) sync persisted device ids with the actual selected devices
|
||||
- 💄(frontend) hide the ProConnect button on narrow viewports
|
||||
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
|
||||
- 🐛(frontend) fix permission store regression
|
||||
- 🐛(frontend) handle missing device errors gracefully
|
||||
- 🐛(frontend) display the meeting id in the join screen page title
|
||||
|
||||
## [1.25.2] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) serve MediaPipe assets under a versioned path
|
||||
- 🐛(frontend) harmonize cache configuration for MediaPipe assets
|
||||
|
||||
## [1.25.1] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch
|
||||
|
||||
## [1.25.0] - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
@@ -17,6 +60,9 @@ and this project adheres to
|
||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||
- ✨(frontend) let users set default configuration for generated links
|
||||
- ✨(frontend) expose media state to external gateways
|
||||
- ✨(frontend) add connection test feature
|
||||
- ✨(sdk) allow passing a background color to the calendar iframe
|
||||
- ✨(sdk) add a room configuration popup from CreateMeetingButton
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -42,6 +88,12 @@ and this project adheres to
|
||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||
- 🚸(frontend) show two initials in the Avatar when possible
|
||||
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
|
||||
- 🐛(frontend) stop the installed app reopening the room it came from
|
||||
- 🐛(backend) serialize lazy title in summary payload
|
||||
- 💄(frontend) show pointer cursor on interactive switches
|
||||
- 🐛(frontend) fix icon centering in the Switch primitive
|
||||
- 🐛(frontend) keep Unicode initials intact in avatar
|
||||
- 🐛(frontend) prevent concurrent settings updates from overwriting each other
|
||||
|
||||
## [1.24.0] - 2026-07-21
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ server {
|
||||
sub_filter_once off;
|
||||
}
|
||||
|
||||
location ^~ /assets/mediapipe/wasm/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
|
||||
@@ -104,3 +104,6 @@ APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
|
||||
APPLICATION_JWT_SECRET_KEY=devKey
|
||||
APPLICATION_BASE_URL=http://localhost:3000
|
||||
|
||||
# Diagnostics
|
||||
CONNECTION_TEST_ENABLED = True
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.24.0"
|
||||
version = "1.26.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.24.0"
|
||||
version = "1.26.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -65,6 +65,7 @@ def get_frontend_configuration(request):
|
||||
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
},
|
||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||
"diagnostics": {"connection_test_enabled": settings.CONNECTION_TEST_ENABLED},
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||
|
||||
@@ -17,6 +17,7 @@ class FeatureFlag:
|
||||
"addons": "ADDONS_ENABLED",
|
||||
"application": "APPLICATION_ENABLED",
|
||||
"roomkit": "ROOMKIT_ENABLED",
|
||||
"connection_test": "CONNECTION_TEST_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -85,3 +85,15 @@ class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
||||
"""
|
||||
|
||||
scope = "roomkit_join"
|
||||
|
||||
|
||||
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle authenticated users requesting connection test tokens."""
|
||||
|
||||
scope = "connection_test"
|
||||
|
||||
|
||||
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous users requesting connection test tokens."""
|
||||
|
||||
scope = "connection_test"
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
from urllib.parse import unquote, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
@@ -27,6 +29,9 @@ from rest_framework import (
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
from rest_framework import (
|
||||
permissions as drf_permissions,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
@@ -36,6 +41,7 @@ from rest_framework import (
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from core import analytics, enums, models, utils
|
||||
from core.api import throttling
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
@@ -93,7 +99,9 @@ from core.services.room_roles import (
|
||||
RoomRoleService,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.utils import generate_token
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from ..models import RoomAccessLevel
|
||||
@@ -1563,3 +1571,68 @@ class FileViewSet(
|
||||
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
|
||||
class DiagnosticsViewSet(viewsets.ViewSet):
|
||||
"""Endpoints helping users and support diagnose connectivity issues.
|
||||
|
||||
Diagnostics are grouped behind a single prefix so upcoming checks
|
||||
(rtcstats collection, ICE candidate reports, etc.) can be added as new
|
||||
actions rather than new top-level routes.
|
||||
|
||||
They are open to anonymous users: someone who cannot join a room is
|
||||
exactly who needs to run a test, and they may well not be logged in.
|
||||
Each action therefore carries its own throttle scope.
|
||||
"""
|
||||
|
||||
permission_classes = [drf_permissions.AllowAny]
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="connection",
|
||||
url_name="connection",
|
||||
throttle_classes=[
|
||||
throttling.ConnectionTestUserRateThrottle,
|
||||
throttling.ConnectionTestAnonRateThrottle,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("connection_test")
|
||||
def connection(self, request):
|
||||
"""Return a short-lived LiveKit token for an ephemeral test room.
|
||||
|
||||
Going through the room API is not an option here: it is tied to
|
||||
registered meetings, lobby rules and longer-lived tokens. Each call
|
||||
gets its own room so two people testing at the same time never meet.
|
||||
"""
|
||||
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid4()}"
|
||||
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
|
||||
|
||||
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
|
||||
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
|
||||
# is available.
|
||||
if settings.CELERY_ENABLED:
|
||||
max_age = (
|
||||
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
|
||||
+ settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS
|
||||
)
|
||||
delete_connection_test_room.apply_async(
|
||||
args=[room],
|
||||
countdown=max_age,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": room,
|
||||
"token": generate_token(
|
||||
room=room,
|
||||
user=request.user,
|
||||
username="Connection Test",
|
||||
ttl=timedelta(seconds=expires_in),
|
||||
),
|
||||
"expires_in": expires_in,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import get_language, gettext, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import aiohttp
|
||||
@@ -121,7 +121,7 @@ class NotificationService:
|
||||
msg_plain = render_to_string(
|
||||
"mail/text/screen_recording.txt", personalized_context
|
||||
)
|
||||
subject = str(_("Your recording is ready")) # Force translation
|
||||
subject = gettext("Your recording is ready") # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
@@ -192,7 +192,7 @@ class NotificationService:
|
||||
"""Generate title from context or return default."""
|
||||
if recording_datetime is None:
|
||||
with override(locale):
|
||||
return _("Transcription")
|
||||
return gettext("Transcription")
|
||||
|
||||
dt = recording_datetime
|
||||
if owner_timezone:
|
||||
|
||||
@@ -137,6 +137,13 @@ class LiveKitEventsService:
|
||||
|
||||
room_name = data.room.name or data.egress_info.room_name
|
||||
|
||||
if self._is_connection_test_room(room_name):
|
||||
logger.info(
|
||||
"Ignoring webhook event for connection test room '%s'.",
|
||||
room_name,
|
||||
)
|
||||
return
|
||||
|
||||
if self._filter_regex and not self._filter_regex.search(room_name):
|
||||
logger.info("Filtered webhook event for room '%s'", room_name)
|
||||
return
|
||||
@@ -228,6 +235,11 @@ class LiveKitEventsService:
|
||||
|
||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||
|
||||
@staticmethod
|
||||
def _is_connection_test_room(room_name: str) -> bool:
|
||||
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
||||
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
|
||||
|
||||
def _handle_room_started(self, data):
|
||||
"""Handle 'room_started' event."""
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
DeleteRoomRequest,
|
||||
ListRoomsRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
@@ -88,3 +89,30 @@ class RoomManagement:
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@async_to_sync
|
||||
async def delete_room(self, room_name: str):
|
||||
"""Delete a LiveKit room and disconnect all participants.
|
||||
|
||||
Raises:
|
||||
RoomNotFoundException: the room does not exist in LiveKit.
|
||||
RoomManagementException: the deletion otherwise fails.
|
||||
"""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
|
||||
logger.info("Deleted LiveKit room %s", room_name)
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping deletion",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist") from e
|
||||
|
||||
logger.exception("Unexpected error deleting room %s", room_name)
|
||||
raise RoomManagementException("Could not delete room") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Celery tasks for the core app."""
|
||||
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
__all__ = (
|
||||
"delete_connection_test_room",
|
||||
"process_file_deletion",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tasks related to connection test rooms."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@task
|
||||
def delete_connection_test_room(room_name: str):
|
||||
"""Force-delete an ephemeral connection-test room.
|
||||
|
||||
Used as a hard cap so a participant cannot keep an auto-refreshed
|
||||
LiveKit session open indefinitely after requesting a test token.
|
||||
"""
|
||||
prefix = settings.CONNECTION_TEST_ROOM_PREFIX
|
||||
if not room_name.startswith(prefix):
|
||||
logger.error(
|
||||
"Refusing to delete room '%s': expected prefix '%s'.",
|
||||
room_name,
|
||||
prefix,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
RoomManagement().delete_room(room_name)
|
||||
except RoomNotFoundException:
|
||||
# Room may already be gone after empty/departure timeout.
|
||||
logger.info("Connection test room '%s' already gone.", room_name)
|
||||
except RoomManagementException:
|
||||
logger.exception("Failed to delete connection test room '%s'.", room_name)
|
||||
@@ -5,6 +5,7 @@ Test event notification.
|
||||
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import smtplib
|
||||
from unittest import mock
|
||||
|
||||
@@ -418,3 +419,63 @@ def test_notify_summary_service_post_args_without_metadata(
|
||||
mock_is_feature_flag_enabled.assert_called_once_with(
|
||||
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||
)
|
||||
def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
|
||||
mock_get_recording_timestamps,
|
||||
mock_generate_download_s3_url,
|
||||
mock_post,
|
||||
settings,
|
||||
):
|
||||
"""Regression test for a non-JSON-serializable payload when timestamps are missing.
|
||||
|
||||
When the LiveKit egress can no longer be found, ``_get_recording_timestamps``
|
||||
returns ``(None, None)`` and ``_generate_title`` falls back to its default
|
||||
title. That default must be a real ``str``: it used to return a lazy
|
||||
``gettext_lazy`` proxy, which ``json.dumps`` cannot serialize, so the real
|
||||
``requests.post(json=payload)`` call crashed in production with
|
||||
``TypeError: Object of type __proxy__ is not JSON serializable``.
|
||||
"""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||
settings.SCREEN_RECORDING_BASE_URL = None
|
||||
settings.METADATA_COLLECTOR_ENABLED = False
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Daily")
|
||||
owner = factories.UserFactory(
|
||||
email="owner@test.com",
|
||||
sub="owner-sub",
|
||||
language="fr-fr",
|
||||
timezone="Europe/Paris",
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||
)
|
||||
|
||||
# Egress timestamps unavailable -> default-title branch in _generate_title.
|
||||
mock_get_recording_timestamps.return_value = (None, None)
|
||||
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
|
||||
|
||||
mock_response = mock.Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"job_id": "job-77"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = NotificationService._notify_summary_service(recording)
|
||||
|
||||
assert result is True
|
||||
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
title = payload["push_to_docs_config"]["title"]
|
||||
|
||||
# The title must be a plain ``str``, not a lazy translation proxy...
|
||||
assert isinstance(title, str)
|
||||
# ...so the payload serializes exactly the way ``requests`` serializes it.
|
||||
json.dumps(payload)
|
||||
|
||||
@@ -720,6 +720,7 @@ def test_receive_unsupported_event(mock_receive, service):
|
||||
|
||||
# Mock returned data with unsupported event type
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(uuid.uuid4())
|
||||
mock_data.event = "unsupported_event"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
@@ -823,3 +824,33 @@ def test_receive_filter_processes_matching_events(
|
||||
service.receive(mock_request)
|
||||
|
||||
mock_handle_room_started.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
|
||||
def test_receive_ignores_connection_test_room(
|
||||
mock_handle_room_started,
|
||||
mock_handle_room_finished,
|
||||
mock_receive,
|
||||
mock_livekit_config,
|
||||
settings,
|
||||
):
|
||||
"""Should ignore all webhook events for connection test rooms in receive()."""
|
||||
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid.uuid4()}"
|
||||
mock_data.event = "room_started"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
service = LiveKitEventsService()
|
||||
service.receive(mock_request)
|
||||
|
||||
mock_handle_room_started.assert_not_called()
|
||||
mock_handle_room_finished.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for the RoomManagement service."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||
def test_delete_room_calls_livekit(mock_create_livekit_client):
|
||||
"""DeleteRoom is forwarded to the LiveKit API."""
|
||||
mock_api = mock.MagicMock()
|
||||
mock_api.room.delete_room = mock.AsyncMock()
|
||||
mock_api.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
RoomManagement().delete_room("room-abc")
|
||||
|
||||
mock_api.room.delete_room.assert_awaited_once()
|
||||
request = mock_api.room.delete_room.await_args.args[0]
|
||||
assert request.room == "room-abc"
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||
def test_delete_room_raises_not_found(mock_create_livekit_client):
|
||||
"""Missing rooms raise RoomNotFoundException."""
|
||||
mock_api = mock.MagicMock()
|
||||
mock_api.room.delete_room = mock.AsyncMock(
|
||||
side_effect=TwirpError("not_found", "room not found", status=404)
|
||||
)
|
||||
mock_api.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
with pytest.raises(RoomNotFoundException):
|
||||
RoomManagement().delete_room("missing-room")
|
||||
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.utils.create_livekit_client")
|
||||
def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
||||
"""Unexpected Twirp errors raise RoomManagementException."""
|
||||
mock_api = mock.MagicMock()
|
||||
mock_api.room.delete_room = mock.AsyncMock(
|
||||
side_effect=TwirpError("internal", "boom", status=500)
|
||||
)
|
||||
mock_api.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
with pytest.raises(RoomManagementException):
|
||||
RoomManagement().delete_room("room-abc")
|
||||
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for connection test Celery tasks."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
|
||||
|
||||
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||
def test_delete_connection_test_room_calls_room_management(mock_delete_room, settings):
|
||||
"""RoomManagement.delete_room is called for connection-test rooms."""
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
delete_connection_test_room("connection-test-abc")
|
||||
|
||||
mock_delete_room.assert_called_once_with("connection-test-abc")
|
||||
|
||||
|
||||
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||
def test_delete_connection_test_room_refuses_other_rooms(mock_delete_room, settings):
|
||||
"""Refuse to delete rooms outside the connection-test namespace."""
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
delete_connection_test_room("production-room")
|
||||
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||
def test_delete_connection_test_room_ignores_missing_room(mock_delete_room, settings):
|
||||
"""Missing rooms are treated as already cleaned up."""
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
mock_delete_room.side_effect = RoomNotFoundException("Room does not exist")
|
||||
|
||||
delete_connection_test_room("connection-test-gone")
|
||||
|
||||
mock_delete_room.assert_called_once_with("connection-test-gone")
|
||||
|
||||
|
||||
@mock.patch("core.tasks.connection_test.RoomManagement.delete_room")
|
||||
def test_delete_connection_test_room_logs_other_failures(mock_delete_room, settings):
|
||||
"""Unexpected LiveKit failures are swallowed after logging."""
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
mock_delete_room.side_effect = RoomManagementException("Could not delete room")
|
||||
|
||||
delete_connection_test_room("connection-test-fail")
|
||||
|
||||
mock_delete_room.assert_called_once_with("connection-test-fail")
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Test diagnostics API endpoints."""
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.api.throttling import (
|
||||
ConnectionTestAnonRateThrottle,
|
||||
ConnectionTestUserRateThrottle,
|
||||
)
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_diagnostics_connection_url():
|
||||
"""The connection check is exposed under the diagnostics namespace."""
|
||||
assert reverse("diagnostics-connection") == "/api/v1.0/diagnostics/connection/"
|
||||
|
||||
|
||||
def test_api_diagnostics_connection_rejects_get():
|
||||
"""Only POST is exposed, the endpoint has no side effect to trigger."""
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
def test_api_diagnostics_connection_returns_ephemeral_livekit_config(settings, client):
|
||||
"""Each request gets a dedicated room and a short-lived token."""
|
||||
|
||||
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 600
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
|
||||
response_a = client.post("/api/v1.0/diagnostics/connection/")
|
||||
response_b = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response_a.status_code == 200
|
||||
assert response_b.status_code == 200
|
||||
|
||||
data_a = response_a.json()
|
||||
data_b = response_b.json()
|
||||
|
||||
room_a = data_a["livekit"]["room"]
|
||||
room_b = data_b["livekit"]["room"]
|
||||
|
||||
assert room_a.startswith("connection-test-")
|
||||
assert room_b.startswith("connection-test-")
|
||||
uuid.UUID(room_a.removeprefix("connection-test-"))
|
||||
uuid.UUID(room_b.removeprefix("connection-test-"))
|
||||
assert room_a != room_b
|
||||
assert data_a["livekit"]["url"]
|
||||
assert data_a["livekit"]["token"]
|
||||
assert data_a["livekit"]["expires_in"] == 600
|
||||
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
|
||||
|
||||
|
||||
def test_api_diagnostics_connection_token_is_short_lived_for_user(settings, client):
|
||||
"""Connection test tokens expire quickly for users."""
|
||||
|
||||
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
|
||||
|
||||
client = APIClient()
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
config = response.json()["livekit"]
|
||||
payload = jwt.decode(
|
||||
config["token"],
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
|
||||
assert config["expires_in"] == 300
|
||||
assert payload["video"]["room"] == config["room"]
|
||||
assert payload["name"] == "Connection Test"
|
||||
assert payload["video"]["roomAdmin"] is False
|
||||
assert payload["exp"] - payload["nbf"] == 300
|
||||
|
||||
|
||||
@override_settings()
|
||||
def test_api_diagnostics_connection_token_for_authenticated_user(settings, client):
|
||||
"""Logged-in users get a token bound to their own identity."""
|
||||
|
||||
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
|
||||
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = jwt.decode(
|
||||
response.json()["livekit"]["token"],
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
|
||||
assert payload["sub"] == str(user.sub)
|
||||
assert payload["video"]["roomAdmin"] is False
|
||||
assert payload["exp"] - payload["nbf"] == 300
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
|
||||
def test_api_diagnostics_connection_schedules_room_deletion(
|
||||
mock_apply_async, settings, client
|
||||
):
|
||||
"""When Celery is enabled, schedule a hard room delete after max age."""
|
||||
|
||||
settings.CELERY_ENABLED = True
|
||||
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS = 300
|
||||
settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS = 10
|
||||
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
|
||||
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 200
|
||||
room = response.json()["livekit"]["room"]
|
||||
mock_apply_async.assert_called_once_with(args=[room], countdown=310)
|
||||
|
||||
|
||||
@mock.patch("core.api.viewsets.delete_connection_test_room.apply_async")
|
||||
def test_api_diagnostics_connection_skips_room_deletion_without_celery(
|
||||
mock_apply_async, settings, client
|
||||
):
|
||||
"""Without Celery, do not schedule deletion (apply_async would run immediately)."""
|
||||
|
||||
settings.CELERY_ENABLED = False
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_apply_async.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"throttle_class",
|
||||
[ConnectionTestAnonRateThrottle, ConnectionTestUserRateThrottle],
|
||||
)
|
||||
def test_api_diagnostics_connection_is_throttled(throttle_class, client):
|
||||
"""Both throttles stay wired to the action once routed through the viewset."""
|
||||
with (
|
||||
mock.patch.object(throttle_class, "allow_request", return_value=False),
|
||||
mock.patch.object(throttle_class, "wait", return_value=42),
|
||||
):
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
def test_api_diagnostics_connection_feature_flag(client, settings):
|
||||
"""Should return a not found error when the connection diagnostics feature is disabled."""
|
||||
|
||||
settings.CONNECTION_TEST_ENABLED = False
|
||||
|
||||
response = client.post("/api/v1.0/diagnostics/connection/")
|
||||
assert response.status_code == 404
|
||||
@@ -30,6 +30,11 @@ router.register(
|
||||
addons_viewsets.SessionViewSet,
|
||||
basename="addons_sessions",
|
||||
)
|
||||
router.register(
|
||||
"diagnostics",
|
||||
viewsets.DiagnosticsViewSet,
|
||||
basename="diagnostics",
|
||||
)
|
||||
|
||||
# - External API
|
||||
external_router = SimpleRouter()
|
||||
|
||||
@@ -12,6 +12,7 @@ import mimetypes
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
@@ -67,6 +68,7 @@ def generate_token( # noqa: PLR0917
|
||||
sources: Optional[List[str]] = None,
|
||||
role: Optional[str] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
ttl: Optional[timedelta] = None,
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
@@ -82,6 +84,7 @@ def generate_token( # noqa: PLR0917
|
||||
role (Optional[str]): Room's access role if any
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
used as identity when user.is_anonymous.
|
||||
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
|
||||
|
||||
Returns:
|
||||
str: The LiveKit JWT access token.
|
||||
@@ -135,6 +138,8 @@ def generate_token( # noqa: PLR0917
|
||||
}
|
||||
)
|
||||
)
|
||||
if ttl is not None:
|
||||
token = token.with_ttl(ttl)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
@@ -354,6 +354,11 @@ class Base(Configuration):
|
||||
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"connection_test": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="CONNECTION_TEST_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||
@@ -660,6 +665,30 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
default=False,
|
||||
)
|
||||
CONNECTION_TEST_ENABLED = values.BooleanValue(
|
||||
environ_name="CONNECTION_TEST_ENABLED",
|
||||
environ_prefix=None,
|
||||
default=False,
|
||||
)
|
||||
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
|
||||
300,
|
||||
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# The effective room max age is always computed as
|
||||
# CONNECTION_TEST_TOKEN_TTL_SECONDS + this value. Token expiration does
|
||||
# not automatically delete rooms, so once that age is reached, the
|
||||
# cleanup worker will explicitly delete the room if it still exists.
|
||||
CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS = values.PositiveIntegerValue(
|
||||
10,
|
||||
environ_name="CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
CONNECTION_TEST_ROOM_PREFIX = values.Value(
|
||||
"connection-test",
|
||||
environ_name="CONNECTION_TEST_ROOM_PREFIX",
|
||||
environ_prefix=None,
|
||||
)
|
||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
@@ -1270,6 +1299,8 @@ class Test(Base):
|
||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
|
||||
CONNECTION_TEST_ENABLED = True
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.24.0"
|
||||
version = "1.26.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.24.0"
|
||||
version = "1.26.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -36,6 +36,9 @@ WORKDIR /home/frontend
|
||||
ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
ARG VITE_APP_TITLE
|
||||
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
|
||||
@@ -4,6 +4,11 @@ server {
|
||||
server_tokens off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location ^~ /assets/mediapipe/wasm/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
|
||||
Generated
+4
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.24.0",
|
||||
"version": "1.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.24.0",
|
||||
"version": "1.26.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
@@ -15,14 +15,13 @@
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.3.1",
|
||||
@@ -1049,18 +1048,12 @@
|
||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.35",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
||||
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
@@ -4716,15 +4709,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/derive-valtio": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
|
||||
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"valtio": ">=2.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.24.0",
|
||||
"version": "1.26.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -22,14 +22,13 @@
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.3.1",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "/",
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -45,6 +45,9 @@ export interface ApiConfig {
|
||||
subtitle: {
|
||||
enabled: boolean
|
||||
}
|
||||
diagnostics: {
|
||||
connection_test_enabled?: boolean
|
||||
}
|
||||
telephony: {
|
||||
enabled: boolean
|
||||
international_phone_number?: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
||||
import React from 'react'
|
||||
import React, { useLayoutEffect, useMemo } from 'react'
|
||||
|
||||
const avatar = cva({
|
||||
base: {
|
||||
@@ -28,13 +28,34 @@ const avatar = cva({
|
||||
},
|
||||
})
|
||||
|
||||
// Instantiating a segmenter is expensive; create it once and reuse it.
|
||||
const graphemeSegmenter =
|
||||
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
: undefined
|
||||
|
||||
/**
|
||||
* Returns the first user-perceived character. Some Unicode characters span
|
||||
* multiple UTF-16 code units, so a naive index into the string can split them
|
||||
* and yield a broken glyph.
|
||||
*/
|
||||
const getFirstGrapheme = (value: string): string => {
|
||||
if (!value) return ''
|
||||
if (graphemeSegmenter) {
|
||||
const [first] = graphemeSegmenter.segment(value)
|
||||
return first?.segment ?? ''
|
||||
}
|
||||
// Fallback: keeps single code points intact (including surrogate pairs).
|
||||
return Array.from(value)[0] ?? ''
|
||||
}
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
if (!name) return ''
|
||||
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return ''
|
||||
const first = words[0].charAt(0)
|
||||
const second = words.length > 1 ? words[1].charAt(0) : ''
|
||||
return (first + second).toUpperCase()
|
||||
const first = getFirstGrapheme(words[0])
|
||||
const second = words.length > 1 ? getFirstGrapheme(words[1]) : ''
|
||||
return (first + second).toLocaleUpperCase()
|
||||
}
|
||||
|
||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
@@ -44,7 +65,37 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
|
||||
export const Avatar = React.memo(
|
||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||
const initials = getInitials(name)
|
||||
const initials = useMemo(() => getInitials(name), [name])
|
||||
const textRef = React.useRef<SVGTextElement>(null)
|
||||
const [offsetY, setOffsetY] = React.useState(0)
|
||||
|
||||
// Optically center the initials: measure the ink bounding box of the
|
||||
// rendered glyphs and shift them so the box's center sits at the middle
|
||||
// of the viewBox. Works for any font, weight or glyph shape, unlike a
|
||||
// hand-tuned dy offset. getBBox() is in local (pre-transform)
|
||||
// coordinates, so applying the translation never changes the measure.
|
||||
useLayoutEffect(() => {
|
||||
const text = textRef.current
|
||||
if (!text) return
|
||||
|
||||
const center = () => {
|
||||
const box = text.getBBox()
|
||||
// A hidden element measures as an empty box; keep the default then.
|
||||
if (box.height === 0) return
|
||||
setOffsetY(50 - (box.y + box.height / 2))
|
||||
}
|
||||
|
||||
center()
|
||||
// Glyph metrics can change once webfonts finish loading.
|
||||
let cancelled = false
|
||||
document.fonts?.ready.then(() => {
|
||||
if (!cancelled) center()
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initials])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: bgColor, ...style }}
|
||||
@@ -57,16 +108,17 @@ export const Avatar = React.memo(
|
||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||
>
|
||||
<text
|
||||
ref={textRef}
|
||||
x="50"
|
||||
y="50"
|
||||
dy="-0.08em"
|
||||
transform={`translate(0 ${offsetY})`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="52"
|
||||
fontWeight="500"
|
||||
fill="currentColor"
|
||||
>
|
||||
{initials.toUpperCase()}
|
||||
{initials}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from '@/primitives'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const SoundTester = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
@@ -15,7 +16,10 @@ export const SoundTester = () => {
|
||||
try {
|
||||
await audioRef?.current?.setSinkId(deviceId)
|
||||
} catch (error) {
|
||||
console.error(`Error setting sinkId: ${error}`)
|
||||
reportError(
|
||||
'device_switch_failure',
|
||||
new Error(`Error setting sinkId: ${error}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
updateActiveId(activeDeviceId)
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'wouter'
|
||||
import { type PostHog } from 'posthog-js'
|
||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
|
||||
let posthog: PostHog | null = null
|
||||
|
||||
const getPosthog = async () => {
|
||||
if (!posthog) posthog = (await import('posthog-js')).default
|
||||
return posthog
|
||||
}
|
||||
import { getPosthog } from '../utils'
|
||||
|
||||
export const startAnalyticsSession = (data: ApiUser) => {
|
||||
getPosthog().then((ph) => {
|
||||
@@ -38,7 +30,6 @@ export const useAnalytics = ({
|
||||
flags_api_host,
|
||||
isDisabled,
|
||||
}: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,6 +40,13 @@ export const useAnalytics = ({
|
||||
api_host: host,
|
||||
flags_api_host: flags_api_host,
|
||||
person_profiles: 'always',
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageleave: true,
|
||||
capture_exceptions: {
|
||||
capture_unhandled_errors: true,
|
||||
capture_unhandled_rejections: true,
|
||||
capture_console_errors: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
}, [id, host, flags_api_host, isDisabled])
|
||||
@@ -58,12 +56,5 @@ export const useAnalytics = ({
|
||||
startAnalyticsSession(user)
|
||||
}, [user])
|
||||
|
||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||
useEffect(() => {
|
||||
getPosthog().then((ph) => {
|
||||
ph.capture('$pageview')
|
||||
})
|
||||
}, [location])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { getPosthog } from './utils'
|
||||
|
||||
export const captureEvent = (
|
||||
event: string,
|
||||
props?: Record<string, unknown>
|
||||
) => {
|
||||
void getPosthog()
|
||||
.then((ph) => {
|
||||
ph.capture(event, props)
|
||||
})
|
||||
.catch(() => {
|
||||
/* telemetry must never break the app */
|
||||
})
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[telemetry] ${event}`, props)
|
||||
}
|
||||
}
|
||||
|
||||
export type LogCode =
|
||||
// media
|
||||
| 'join_preview_failure'
|
||||
| 'room_media_failure'
|
||||
| 'livekit_room_error'
|
||||
| 'device_switch_failure'
|
||||
| 'permission_poll_failure'
|
||||
// non-media families
|
||||
| 'participant_mute_api_failure'
|
||||
| 'permissions_api_failure'
|
||||
| 'effects_processor_failure'
|
||||
| 'clipboard_failure'
|
||||
| 'fullscreen_failure'
|
||||
| 'publish_sources_failure'
|
||||
| 'disconnect_failure'
|
||||
| 'generic_failure'
|
||||
|
||||
export const reportError = (
|
||||
logCode: LogCode,
|
||||
error: unknown,
|
||||
extraInfo: Record<string, unknown> = {}
|
||||
): void => {
|
||||
const e = error instanceof Error ? error : new Error(String(error))
|
||||
void getPosthog()
|
||||
.then((ph) => {
|
||||
ph.captureException(e, {
|
||||
log_code: logCode,
|
||||
error_name: e.name,
|
||||
error_message: e.message,
|
||||
...extraInfo,
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[${logCode}]`, e, extraInfo)
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceSnapshot {
|
||||
cam_count: number
|
||||
mic_count: number
|
||||
out_count: number
|
||||
labels_visible: boolean
|
||||
saved_cam_present: boolean | null
|
||||
saved_mic_present: boolean | null
|
||||
saved_video_device_id_set: boolean
|
||||
saved_audio_device_id_set: boolean
|
||||
audio_enabled: boolean | null
|
||||
video_enabled: boolean | null
|
||||
cam_permission: PermissionState | 'unknown'
|
||||
mic_permission: PermissionState | 'unknown'
|
||||
}
|
||||
|
||||
/** Reads the persisted LiveKit user choices without importing the store. */
|
||||
const readPersistedChoices = (): {
|
||||
videoDeviceId?: string
|
||||
audioDeviceId?: string
|
||||
videoEnabled?: boolean
|
||||
audioEnabled?: boolean
|
||||
} => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('lk-user-choices') ?? '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const queryPermission = async (
|
||||
name: 'camera' | 'microphone'
|
||||
): Promise<PermissionState | 'unknown'> => {
|
||||
try {
|
||||
const status = await navigator.permissions.query({
|
||||
name: name as PermissionName,
|
||||
})
|
||||
return status.state
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceSnapshot = async (): Promise<DeviceSnapshot> => {
|
||||
const choices = readPersistedChoices()
|
||||
let devices: MediaDeviceInfo[] = []
|
||||
try {
|
||||
devices = await navigator.mediaDevices.enumerateDevices()
|
||||
} catch {
|
||||
/* snapshot stays partial */
|
||||
}
|
||||
const ofKind = (k: MediaDeviceKind) => devices.filter((d) => d.kind === k)
|
||||
const present = (k: MediaDeviceKind, id?: string) =>
|
||||
id ? ofKind(k).some((d) => d.deviceId === id) : null
|
||||
|
||||
const [cam_permission, mic_permission] = await Promise.all([
|
||||
queryPermission('camera'),
|
||||
queryPermission('microphone'),
|
||||
])
|
||||
|
||||
return {
|
||||
cam_count: ofKind('videoinput').length,
|
||||
mic_count: ofKind('audioinput').length,
|
||||
out_count: ofKind('audiooutput').length,
|
||||
labels_visible: devices.some((d) => !!d.label),
|
||||
saved_cam_present: present('videoinput', choices.videoDeviceId),
|
||||
saved_mic_present: present('audioinput', choices.audioDeviceId),
|
||||
saved_video_device_id_set: !!choices.videoDeviceId,
|
||||
saved_audio_device_id_set: !!choices.audioDeviceId,
|
||||
audio_enabled: choices.audioEnabled ?? null,
|
||||
video_enabled: choices.videoEnabled ?? null,
|
||||
cam_permission,
|
||||
mic_permission,
|
||||
}
|
||||
}
|
||||
|
||||
export const captureMediaEvent = async (
|
||||
event:
|
||||
| 'media-device-error'
|
||||
| 'media-acquisition'
|
||||
| 'media-device-topology'
|
||||
| 'media-device-success'
|
||||
| 'device-not-found'
|
||||
| 'permissions-denied'
|
||||
| 'silent-mic-detected'
|
||||
| 'silent-mic-analyser-unavailable'
|
||||
| 'silent-mic-recovered'
|
||||
| 'visit-room'
|
||||
| 'connection-event',
|
||||
props: Record<string, unknown>
|
||||
) => {
|
||||
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
|
||||
let posthog: PostHog | null = null
|
||||
|
||||
export const getPosthog = async () => {
|
||||
if (!posthog) posthog = (await import('posthog-js')).default
|
||||
return posthog
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export type LiveKitConnectionDetails = {
|
||||
url: string
|
||||
room: string
|
||||
token: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export type ConnectionTestResponse = {
|
||||
livekit: LiveKitConnectionDetails
|
||||
}
|
||||
|
||||
export const fetchConnectionTestDetails = () =>
|
||||
fetchApi<ConnectionTestResponse>('/diagnostics/connection/', {
|
||||
method: 'POST',
|
||||
})
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Checker, Track, type CheckInfo } from 'livekit-client'
|
||||
|
||||
/**
|
||||
* Addresses are useful to a network administrator (they identify the egress IP
|
||||
* and the SFU endpoint actually reached) but they also land in a downloadable
|
||||
* report. Flip this to false to keep only the candidate types and protocols.
|
||||
*/
|
||||
const INCLUDE_CANDIDATE_ADDRESSES = true
|
||||
|
||||
/** Beyond this, the log becomes noise rather than evidence. */
|
||||
const MAX_LOGGED_PAIRS = 8
|
||||
|
||||
export type IceCandidateInfo = {
|
||||
/** host, srflx, prflx or relay. */
|
||||
type?: string
|
||||
/** Transport to the first hop: udp or tcp. */
|
||||
protocol?: string
|
||||
/** Transport used by the relay itself (udp, tcp, tls). Local relay only. */
|
||||
relayProtocol?: string
|
||||
/** Chrome reports an mDNS `.local` name here for host candidates. */
|
||||
address?: string
|
||||
port?: number
|
||||
/** Local candidates only, and not reported by every browser. */
|
||||
networkType?: string
|
||||
}
|
||||
|
||||
export type IceCandidatePair = {
|
||||
/** The pair the browser is actually sending media on. */
|
||||
selected: boolean
|
||||
nominated?: boolean
|
||||
local: IceCandidateInfo
|
||||
remote: IceCandidateInfo
|
||||
/** Round trip time in milliseconds. */
|
||||
rttMs?: number
|
||||
availableOutgoingBitrate?: number
|
||||
bytesSent?: number
|
||||
}
|
||||
|
||||
export type IceCandidateReport = {
|
||||
selected: IceCandidatePair | null
|
||||
/** Every pair that completed its connectivity checks, selected one first. */
|
||||
working: IceCandidatePair[]
|
||||
}
|
||||
|
||||
const PROBE_WIDTH = 320
|
||||
const PROBE_HEIGHT = 180
|
||||
const PROBE_FPS = 15
|
||||
const SETTLE_DELAY_MS = 3000
|
||||
|
||||
type Stats = Record<string, unknown> & { type?: string }
|
||||
|
||||
const readCandidate = (stats?: Stats): IceCandidateInfo => {
|
||||
if (!stats) return {}
|
||||
|
||||
return {
|
||||
type: stats.candidateType as string | undefined,
|
||||
protocol: stats.protocol as string | undefined,
|
||||
relayProtocol: stats.relayProtocol as string | undefined,
|
||||
networkType: stats.networkType as string | undefined,
|
||||
...(INCLUDE_CANDIDATE_ADDRESSES
|
||||
? {
|
||||
address: stats.address as string | undefined,
|
||||
port: stats.port as number | undefined,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
const describeCandidate = (candidate: IceCandidateInfo) => {
|
||||
const transport = candidate.relayProtocol ?? candidate.protocol ?? 'unknown'
|
||||
const endpoint =
|
||||
candidate.address === undefined
|
||||
? ''
|
||||
: ` ${candidate.address}:${candidate.port ?? '?'}`
|
||||
return `${candidate.type ?? 'unknown'} ${transport}${endpoint}`
|
||||
}
|
||||
|
||||
const parseCandidates = (report: RTCStatsReport): IceCandidateReport => {
|
||||
let selectedId: string | undefined
|
||||
|
||||
report.forEach((stats: Stats) => {
|
||||
if (stats.type === 'transport' && stats.selectedCandidatePairId) {
|
||||
selectedId = stats.selectedCandidatePairId as string
|
||||
}
|
||||
})
|
||||
|
||||
const working: IceCandidatePair[] = []
|
||||
|
||||
report.forEach((stats: Stats) => {
|
||||
// `succeeded` means the pair completed its connectivity checks; failed,
|
||||
// waiting and in-progress pairs are not evidence of anything working.
|
||||
if (stats.type !== 'candidate-pair' || stats.state !== 'succeeded') return
|
||||
|
||||
const rtt = stats.currentRoundTripTime as number | undefined
|
||||
|
||||
working.push({
|
||||
selected: selectedId !== undefined && stats.id === selectedId,
|
||||
nominated: stats.nominated as boolean | undefined,
|
||||
local: readCandidate(report.get(stats.localCandidateId as string)),
|
||||
remote: readCandidate(report.get(stats.remoteCandidateId as string)),
|
||||
rttMs: rtt === undefined ? undefined : Math.round(rtt * 1000),
|
||||
availableOutgoingBitrate: stats.availableOutgoingBitrate as
|
||||
| number
|
||||
| undefined,
|
||||
bytesSent: stats.bytesSent as number | undefined,
|
||||
})
|
||||
})
|
||||
|
||||
// Firefox does not report transport.selectedCandidatePairId: fall back to the
|
||||
// nominated pair, then to the one that actually carried bytes.
|
||||
let selected = working.find((pair) => pair.selected) ?? null
|
||||
if (!selected) {
|
||||
selected =
|
||||
working.find((pair) => pair.nominated) ??
|
||||
working
|
||||
.slice()
|
||||
.sort((a, b) => (b.bytesSent ?? 0) - (a.bytesSent ?? 0))[0] ??
|
||||
null
|
||||
if (selected) selected.selected = true
|
||||
}
|
||||
|
||||
working.sort((a, b) => Number(b.selected) - Number(a.selected))
|
||||
|
||||
return { selected, working }
|
||||
}
|
||||
|
||||
/**
|
||||
* A synthetic track avoids asking for camera or microphone permission: this
|
||||
* check must work for someone who denied both.
|
||||
*/
|
||||
const createProbeTrack = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = PROBE_WIDTH
|
||||
canvas.height = PROBE_HEIGHT
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('Could not get canvas context')
|
||||
|
||||
let frame = 0
|
||||
let rafId = 0
|
||||
const draw = () => {
|
||||
frame = (frame + 4) % 360
|
||||
context.fillStyle = `hsl(${frame}, 100%, 50%)`
|
||||
context.fillRect(0, 0, canvas.width, canvas.height)
|
||||
rafId = requestAnimationFrame(draw)
|
||||
}
|
||||
draw()
|
||||
|
||||
const track = canvas.captureStream(PROBE_FPS).getVideoTracks()[0]
|
||||
|
||||
return {
|
||||
track,
|
||||
stop: () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
track.stop()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export class SelectedCandidateCheck extends Checker {
|
||||
private result: IceCandidateReport | null = null
|
||||
|
||||
get description() {
|
||||
const selected = this.result?.selected
|
||||
if (!selected) return 'Selected ICE candidate pair'
|
||||
|
||||
const transport =
|
||||
selected.local.relayProtocol ?? selected.local.protocol ?? 'unknown'
|
||||
const rtt =
|
||||
selected.rttMs === undefined ? '' : ` · RTT ${selected.rttMs} ms`
|
||||
return `${selected.local.type ?? 'unknown'} over ${transport}${rtt}`
|
||||
}
|
||||
|
||||
protected async perform() {
|
||||
await this.connect()
|
||||
|
||||
const probe = createProbeTrack()
|
||||
try {
|
||||
let publication
|
||||
try {
|
||||
publication = await this.room.localParticipant.publishTrack(
|
||||
probe.track,
|
||||
{
|
||||
// The token restricts `can_publish_sources`, so a raw
|
||||
// MediaStreamTrack published as `unknown` is rejected server side.
|
||||
source: Track.Source.Camera,
|
||||
simulcast: false,
|
||||
videoEncoding: { maxBitrate: 300_000, maxFramerate: PROBE_FPS },
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
// A server-side grant problem is not a diagnosis of the user's network.
|
||||
this.appendWarning(
|
||||
`Could not publish the probe track: ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`
|
||||
)
|
||||
this.skip()
|
||||
return
|
||||
}
|
||||
|
||||
// ICE keeps promoting pairs for a moment after the track goes up.
|
||||
await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY_MS))
|
||||
|
||||
// Stats come from the publisher peer connection: in an empty test room
|
||||
// there is no subscriber transport to inspect.
|
||||
const report = await publication.track?.getRTCStatsReport()
|
||||
this.result = report ? parseCandidates(report) : null
|
||||
} finally {
|
||||
probe.stop()
|
||||
}
|
||||
|
||||
const selected = this.result?.selected
|
||||
const working = this.result?.working ?? []
|
||||
|
||||
if (!selected) {
|
||||
this.appendWarning('No working candidate pair reported by the browser')
|
||||
return
|
||||
}
|
||||
|
||||
this.appendMessage(`selected: ${describeCandidate(selected.local)}`)
|
||||
this.appendMessage(`server: ${describeCandidate(selected.remote)}`)
|
||||
if (selected.rttMs !== undefined) {
|
||||
this.appendMessage(`round trip time: ${selected.rttMs} ms`)
|
||||
}
|
||||
|
||||
this.appendMessage(`working candidate pairs: ${working.length}`)
|
||||
for (const pair of working.slice(0, MAX_LOGGED_PAIRS)) {
|
||||
const rtt = pair.rttMs === undefined ? '' : ` · ${pair.rttMs} ms`
|
||||
this.appendMessage(
|
||||
`${pair.selected ? '→' : ' '} ${describeCandidate(pair.local)} → ${describeCandidate(pair.remote)}${rtt}`
|
||||
)
|
||||
}
|
||||
if (working.length > MAX_LOGGED_PAIRS) {
|
||||
this.appendMessage(
|
||||
`… and ${working.length - MAX_LOGGED_PAIRS} more, see the report`
|
||||
)
|
||||
}
|
||||
|
||||
if (selected.local.type === 'relay') {
|
||||
this.appendWarning(
|
||||
'Media is relayed through TURN. Direct connections are likely blocked by a firewall.'
|
||||
)
|
||||
}
|
||||
if ((selected.local.relayProtocol ?? selected.local.protocol) !== 'udp') {
|
||||
this.appendWarning(
|
||||
'Media is not using UDP, which usually means degraded quality under load.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
getInfo(): CheckInfo {
|
||||
const info = super.getInfo()
|
||||
info.data = this.result ?? undefined
|
||||
return info
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosurePanel,
|
||||
Heading,
|
||||
Button as RACButton,
|
||||
} from 'react-aria-components'
|
||||
import { RiArrowDownSFill } from '@remixicon/react'
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import type { ConnectionTestStepResult } from '../types'
|
||||
import { StepStatusIndicator } from './StepStatusIndicator'
|
||||
|
||||
/** Each step is its own bounded card, collapsed or not. */
|
||||
const cardClass = css({
|
||||
border: '1px solid {colors.greyscale.900}',
|
||||
borderRadius: '5px',
|
||||
backgroundColor: 'white',
|
||||
overflow: 'hidden',
|
||||
})
|
||||
|
||||
/**
|
||||
* Fixed columns so the status labels line up across every row, whether or not
|
||||
* the row is expandable.
|
||||
*/
|
||||
const rowClass = css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) 7rem 1.5rem',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
width: '100%',
|
||||
paddingX: '1rem',
|
||||
paddingY: '0.75rem',
|
||||
textAlign: 'left',
|
||||
})
|
||||
|
||||
const identityClass = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.125rem',
|
||||
minWidth: 0,
|
||||
})
|
||||
|
||||
const triggerClass = css({
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 120ms',
|
||||
_hover: { backgroundColor: 'greyscale.50' },
|
||||
'&[data-focus-visible]': {
|
||||
outline: '2px solid {colors.focusRing}',
|
||||
outlineOffset: '-2px',
|
||||
},
|
||||
})
|
||||
|
||||
/** Expanded headers stay tinted so the open card reads as one block. */
|
||||
const triggerExpandedClass = css({
|
||||
backgroundColor: 'greyscale.100',
|
||||
_hover: { backgroundColor: 'greyscale.100' },
|
||||
})
|
||||
|
||||
const labelClass = css({
|
||||
textStyle: 'body',
|
||||
color: 'greyscale.1000',
|
||||
fontWeight: 'medium',
|
||||
})
|
||||
|
||||
const valueClass = css({
|
||||
fontFamily: 'mono',
|
||||
textStyle: 'xs',
|
||||
color: 'greyscale.500',
|
||||
overflowWrap: 'anywhere',
|
||||
})
|
||||
|
||||
const chevronClass = css({
|
||||
color: 'primary.800',
|
||||
justifySelf: 'end',
|
||||
transition: 'transform 150ms',
|
||||
})
|
||||
|
||||
const chevronExpandedClass = css({ transform: 'rotate(180deg)' })
|
||||
|
||||
const headingResetClass = css({
|
||||
margin: 0,
|
||||
fontSize: 'inherit',
|
||||
fontWeight: 'inherit',
|
||||
})
|
||||
|
||||
const panelClass = css({
|
||||
backgroundColor: 'white',
|
||||
})
|
||||
|
||||
const logListClass = css({
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
})
|
||||
|
||||
const logItemClass = css({
|
||||
fontFamily: 'mono',
|
||||
textStyle: 'xs',
|
||||
color: 'greyscale.700',
|
||||
overflowWrap: 'anywhere',
|
||||
})
|
||||
|
||||
const StepRowContent = ({ step }: { step: ConnectionTestStepResult }) => {
|
||||
const { t } = useTranslation('connectionTest')
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={identityClass}>
|
||||
<span className={labelClass}>{t(`steps.${step.id}`)}</span>
|
||||
{step.summary && <span className={valueClass}>{step.summary}</span>}
|
||||
</span>
|
||||
<StepStatusIndicator
|
||||
status={step.status}
|
||||
label={t(`status.${step.status}`)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ConnectionTestStepRow = ({
|
||||
step,
|
||||
}: {
|
||||
step: ConnectionTestStepResult
|
||||
}) => {
|
||||
const { t } = useTranslation('connectionTest')
|
||||
const isSettled = step.status !== 'pending' && step.status !== 'running'
|
||||
const hasLogs = isSettled && Boolean(step.logs?.length)
|
||||
|
||||
if (!hasLogs) {
|
||||
return (
|
||||
<div className={cx(cardClass, rowClass)}>
|
||||
<StepRowContent step={step} />
|
||||
{/* Empty chevron column keeps non-expandable rows aligned. */}
|
||||
<span />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Disclosure className={cardClass}>
|
||||
{({ isExpanded }) => (
|
||||
<>
|
||||
<Heading level={3} className={headingResetClass}>
|
||||
<RACButton
|
||||
slot="trigger"
|
||||
className={cx(
|
||||
rowClass,
|
||||
triggerClass,
|
||||
isExpanded ? triggerExpandedClass : undefined
|
||||
)}
|
||||
>
|
||||
<StepRowContent step={step} />
|
||||
<RiArrowDownSFill
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
chevronClass,
|
||||
isExpanded ? chevronExpandedClass : undefined
|
||||
)}
|
||||
/>
|
||||
</RACButton>
|
||||
</Heading>
|
||||
<DisclosurePanel
|
||||
className={panelClass}
|
||||
aria-label={t('detailsFor', { step: t(`steps.${step.id}`) })}
|
||||
style={{ padding: isExpanded ? '0.75rem 1rem' : '0 1rem' }}
|
||||
>
|
||||
{/* Collapsed panels stay in the DOM for aria-controls, but the log
|
||||
lines themselves are only mounted when actually visible. */}
|
||||
{isExpanded && (
|
||||
<ul className={logListClass}>
|
||||
{step.logs?.map((log, index) => (
|
||||
<li key={`${log.level}-${index}`} className={logItemClass}>
|
||||
{log.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ProgressBar } from 'react-aria-components'
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import type { ConnectionTestStats } from '../types'
|
||||
import { statusSquareClass } from './stepAppearance'
|
||||
|
||||
type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed'
|
||||
|
||||
/** 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' }),
|
||||
}
|
||||
|
||||
const cardClass = css({
|
||||
width: '100%',
|
||||
borderRadius: '5px',
|
||||
border: '1px solid {colors.greyscale.900}',
|
||||
backgroundColor: 'white',
|
||||
padding: { base: '1.25rem', xsm: '1.75rem' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// Blocks are spaced here; everything inside a block stays tight.
|
||||
gap: '1.5rem',
|
||||
})
|
||||
|
||||
const headerClass = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
})
|
||||
|
||||
const eyebrowClass = css({
|
||||
textStyle: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: 'greyscale.600',
|
||||
margin: 0,
|
||||
})
|
||||
|
||||
const headlineClass = css({
|
||||
// Sized for the longest state string ("N vérifications en échec"), not for
|
||||
// the shortest one.
|
||||
fontSize: { base: '28', xsm: '40' },
|
||||
lineHeight: '1.1',
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: '-0.02em',
|
||||
textWrap: 'balance',
|
||||
margin: 0,
|
||||
})
|
||||
|
||||
const hintClass = css({
|
||||
textStyle: 'sm',
|
||||
color: 'greyscale.600',
|
||||
margin: 0,
|
||||
maxWidth: '34rem',
|
||||
})
|
||||
|
||||
const dividerClass = css({
|
||||
// Lighter than the card border: an inner rule should never compete with it.
|
||||
borderTop: '1px solid {colors.greyscale.100}',
|
||||
paddingTop: '1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.875rem',
|
||||
})
|
||||
|
||||
const progressRowClass = css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
})
|
||||
|
||||
const trackClass = css({
|
||||
height: '0.375rem',
|
||||
width: '100%',
|
||||
borderRadius: 'full',
|
||||
backgroundColor: 'greyscale.200',
|
||||
overflow: 'hidden',
|
||||
})
|
||||
|
||||
const fillClass = css({
|
||||
height: '100%',
|
||||
borderRadius: 'full',
|
||||
backgroundColor: 'primary.800',
|
||||
transition: 'width 200ms ease-out',
|
||||
})
|
||||
|
||||
const progressValueClass = css({
|
||||
textStyle: 'sm',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: 'greyscale.700',
|
||||
whiteSpace: 'nowrap',
|
||||
// Reserved width so the bar does not resize when the digits change.
|
||||
minWidth: '3rem',
|
||||
textAlign: 'right',
|
||||
})
|
||||
|
||||
const countersClass = css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem 1.5rem',
|
||||
})
|
||||
|
||||
const counterClass = css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
textStyle: 'sm',
|
||||
color: 'greyscale.600',
|
||||
})
|
||||
|
||||
const counterSquareClass = css({
|
||||
width: '0.5rem',
|
||||
height: '0.5rem',
|
||||
borderRadius: '2px',
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
const counterValueClass = css({
|
||||
fontWeight: 'medium',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: 'greyscale.1000',
|
||||
})
|
||||
|
||||
/** A zero count is context, not a result: it recedes instead of shouting. */
|
||||
const emptyCounterClass = css({ color: 'greyscale.400' })
|
||||
const emptySquareClass = css({
|
||||
backgroundColor: 'transparent!',
|
||||
border: '1px solid {colors.greyscale.250}',
|
||||
})
|
||||
|
||||
const actionsClass = css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
})
|
||||
|
||||
const Counter = ({
|
||||
squareClass,
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
squareClass: string
|
||||
value: number
|
||||
label: string
|
||||
}) => {
|
||||
const isEmpty = value === 0
|
||||
|
||||
return (
|
||||
<span className={cx(counterClass, isEmpty ? emptyCounterClass : undefined)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(
|
||||
counterSquareClass,
|
||||
squareClass,
|
||||
isEmpty ? emptySquareClass : undefined
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cx(
|
||||
counterValueClass,
|
||||
isEmpty ? emptyCounterClass : undefined
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const ConnectionTestSummary = ({
|
||||
stats,
|
||||
isRunning,
|
||||
children,
|
||||
}: {
|
||||
stats: ConnectionTestStats
|
||||
isRunning: boolean
|
||||
children?: ReactNode
|
||||
}) => {
|
||||
const { t } = useTranslation('connectionTest')
|
||||
|
||||
const state: SummaryState = isRunning
|
||||
? 'running'
|
||||
: !stats.hasStarted
|
||||
? 'idle'
|
||||
: stats.failed > 0
|
||||
? 'failed'
|
||||
: stats.skipped > 0
|
||||
? 'partial'
|
||||
: 'passed'
|
||||
|
||||
return (
|
||||
<section className={cardClass}>
|
||||
<div className={headerClass}>
|
||||
<h1 className={eyebrowClass}>{t('title')}</h1>
|
||||
|
||||
{/* Announced once per state change rather than on every step update. */}
|
||||
<p className={cx(headlineClass, stateColorClass[state])} role="status">
|
||||
{state === 'failed'
|
||||
? t('summary.failed', { count: stats.failed })
|
||||
: t(`summary.${state}`)}
|
||||
</p>
|
||||
|
||||
<p className={hintClass}>{t(`summary.${state}Hint`)}</p>
|
||||
</div>
|
||||
|
||||
{stats.hasStarted && (
|
||||
<div className={dividerClass}>
|
||||
<div className={progressRowClass}>
|
||||
<ProgressBar
|
||||
aria-label={t('progressLabel')}
|
||||
value={stats.progress}
|
||||
className={css({ flex: 1 })}
|
||||
>
|
||||
{({ percentage }) => (
|
||||
<div className={trackClass}>
|
||||
<div
|
||||
className={fillClass}
|
||||
style={{ width: `${percentage ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ProgressBar>
|
||||
<span className={progressValueClass}>
|
||||
{t('progress', { done: stats.settled, total: stats.total })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={countersClass}>
|
||||
<Counter
|
||||
squareClass={statusSquareClass.success}
|
||||
value={stats.passed}
|
||||
label={t('counts.passed')}
|
||||
/>
|
||||
<Counter
|
||||
squareClass={statusSquareClass.skipped}
|
||||
value={stats.skipped}
|
||||
label={t('counts.skipped')}
|
||||
/>
|
||||
<Counter
|
||||
squareClass={statusSquareClass.failed}
|
||||
value={stats.failed}
|
||||
label={t('counts.failed')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children && <div className={actionsClass}>{children}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import type { ConnectionTestStepStatus } from '../types'
|
||||
import { statusSquareClass, statusTextClass } from './stepAppearance'
|
||||
|
||||
const wrapperClass = css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
textStyle: 'sm',
|
||||
whiteSpace: 'nowrap',
|
||||
})
|
||||
|
||||
const squareClass = css({
|
||||
width: '0.625rem',
|
||||
height: '0.625rem',
|
||||
borderRadius: '2px',
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
/**
|
||||
* Status is carried by the label; the square is decorative so the meaning does
|
||||
* not depend on colour alone.
|
||||
*/
|
||||
export const StepStatusIndicator = ({
|
||||
status,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
status: ConnectionTestStepStatus
|
||||
label: string
|
||||
className?: string
|
||||
}) => (
|
||||
<span className={cx(wrapperClass, className)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cx(squareClass, statusSquareClass[status])}
|
||||
/>
|
||||
<span className={statusTextClass[status]}>{label}</span>
|
||||
</span>
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import type { ConnectionTestStepStatus } from '../types'
|
||||
|
||||
/**
|
||||
* Panda extracts styles statically, so every status needs its own literal
|
||||
* `css()` call: `css({ backgroundColor: someVariable })` would emit nothing.
|
||||
*/
|
||||
export const statusSquareClass: Record<ConnectionTestStepStatus, string> = {
|
||||
pending: css({
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid {colors.greyscale.300}',
|
||||
}),
|
||||
running: css({
|
||||
backgroundColor: 'primary.800',
|
||||
animation: 'pulse_background 1.2s ease-in-out infinite',
|
||||
}),
|
||||
success: css({ backgroundColor: 'success.600' }),
|
||||
failed: css({ backgroundColor: 'danger.600' }),
|
||||
skipped: css({ backgroundColor: 'greyscale.300' }),
|
||||
}
|
||||
|
||||
/** Colour is carried by the square; the label stays near-black except on failure. */
|
||||
export const statusTextClass: Record<ConnectionTestStepStatus, string> = {
|
||||
pending: css({ color: 'greyscale.500' }),
|
||||
running: css({ color: 'greyscale.700' }),
|
||||
success: css({ color: 'greyscale.1000' }),
|
||||
failed: css({ color: 'danger.600', fontWeight: 'medium' }),
|
||||
skipped: css({ color: 'greyscale.500' }),
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
CheckStatus,
|
||||
ConnectionCheck,
|
||||
createLocalAudioTrack,
|
||||
createLocalVideoTrack,
|
||||
getBrowser,
|
||||
type CheckInfo,
|
||||
} from 'livekit-client'
|
||||
import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails'
|
||||
import { SelectedCandidateCheck } from '../checks/selectedCandidate'
|
||||
import {
|
||||
createInitialSteps,
|
||||
type ConnectionTestLog,
|
||||
type ConnectionTestStepId,
|
||||
type ConnectionTestStepResult,
|
||||
type ConnectionTestStepStatus,
|
||||
} from '../types'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
|
||||
const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
|
||||
'websocket',
|
||||
'webrtc',
|
||||
'turn',
|
||||
'reconnect',
|
||||
'selectedCandidate',
|
||||
'publishAudio',
|
||||
'publishVideo',
|
||||
]
|
||||
|
||||
const CHECK_STATUS_TO_STEP: Record<CheckStatus, ConnectionTestStepStatus> = {
|
||||
[CheckStatus.IDLE]: 'pending',
|
||||
[CheckStatus.RUNNING]: 'running',
|
||||
[CheckStatus.SUCCESS]: 'success',
|
||||
[CheckStatus.FAILED]: 'failed',
|
||||
[CheckStatus.SKIPPED]: 'skipped',
|
||||
}
|
||||
|
||||
/** getUserMedia rejections that mean "the user said no", not "the device is broken". */
|
||||
const PERMISSION_ERROR_NAMES = new Set([
|
||||
'NotAllowedError',
|
||||
'PermissionDeniedError',
|
||||
'SecurityError',
|
||||
])
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
|
||||
error instanceof Error ? error.message : fallback
|
||||
|
||||
const isPermissionError = (error: unknown) =>
|
||||
error instanceof Error && PERMISSION_ERROR_NAMES.has(error.name)
|
||||
|
||||
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
|
||||
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
||||
summary: info.description,
|
||||
logs: info.logs,
|
||||
})
|
||||
|
||||
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
||||
const grouped: Record<string, string[]> = {
|
||||
audioinput: [],
|
||||
audiooutput: [],
|
||||
videoinput: [],
|
||||
}
|
||||
for (const device of devices) {
|
||||
// Browsers are free to report kinds we don't know about yet.
|
||||
const bucket = (grouped[device.kind] ??= [])
|
||||
bucket.push(device.label || device.deviceId)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a single step. `aborted` is deliberately distinct from `failed`:
|
||||
* a cancelled run must not be reported to the user as a broken device.
|
||||
*/
|
||||
type StepOutcome =
|
||||
| { state: 'success' }
|
||||
| { state: 'failed'; error: unknown }
|
||||
| { state: 'aborted' }
|
||||
|
||||
const ABORTED: StepOutcome = { state: 'aborted' }
|
||||
|
||||
export const useConnectionTestRunner = () => {
|
||||
const [steps, setSteps] = useState(createInitialSteps)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const updateStep = useCallback(
|
||||
(id: ConnectionTestStepId, patch: Partial<ConnectionTestStepResult>) => {
|
||||
setSteps((current) =>
|
||||
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const skipSteps = useCallback(
|
||||
(
|
||||
ids: ConnectionTestStepId[],
|
||||
summary: string,
|
||||
logs?: ConnectionTestLog[]
|
||||
) => {
|
||||
// One state update for the whole batch instead of one per step.
|
||||
const targets = new Set(ids)
|
||||
setSteps((current) =>
|
||||
current.map((step) =>
|
||||
targets.has(step.id)
|
||||
? { ...step, status: 'skipped', summary, logs }
|
||||
: step
|
||||
)
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const runStep = useCallback(
|
||||
async (
|
||||
id: ConnectionTestStepId,
|
||||
signal: AbortSignal,
|
||||
fn: () => Promise<Partial<ConnectionTestStepResult>>
|
||||
): Promise<StepOutcome> => {
|
||||
if (signal.aborted) return ABORTED
|
||||
|
||||
updateStep(id, {
|
||||
status: 'running',
|
||||
summary: undefined,
|
||||
logs: undefined,
|
||||
data: undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await fn()
|
||||
if (signal.aborted) return ABORTED
|
||||
// `result.status` overrides when set (LiveKit checks map their own status)
|
||||
updateStep(id, { status: 'success', ...result })
|
||||
return { state: 'success' }
|
||||
} catch (error) {
|
||||
if (signal.aborted) return ABORTED
|
||||
updateStep(id, {
|
||||
status: 'failed',
|
||||
summary: getErrorMessage(error),
|
||||
})
|
||||
return { state: 'failed', error }
|
||||
}
|
||||
},
|
||||
[updateStep]
|
||||
)
|
||||
|
||||
const runTest = useCallback(async () => {
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const { signal } = controller
|
||||
|
||||
setIsRunning(true)
|
||||
setSteps(createInitialSteps())
|
||||
|
||||
try {
|
||||
await runStep('browser', signal, async () => {
|
||||
const browser = getBrowser()
|
||||
if (!browser) throw new Error('Browser not detected')
|
||||
return {
|
||||
summary: `${browser.name} ${browser.version}`,
|
||||
data: {
|
||||
name: browser.name,
|
||||
version: browser.version,
|
||||
os: browser.os,
|
||||
osVersion: browser.osVersion,
|
||||
},
|
||||
}
|
||||
})
|
||||
if (signal.aborted) return
|
||||
|
||||
const microphone = await runStep('microphone', signal, async () => {
|
||||
const track = await createLocalAudioTrack()
|
||||
const label =
|
||||
track.mediaStreamTrack.label ||
|
||||
track.mediaStreamTrack.getSettings().deviceId ||
|
||||
''
|
||||
track.stop()
|
||||
return { summary: label, data: { label } }
|
||||
})
|
||||
if (signal.aborted) return
|
||||
if (
|
||||
microphone.state === 'failed' &&
|
||||
isPermissionError(microphone.error)
|
||||
) {
|
||||
openPermissionsDialog('audioinput')
|
||||
}
|
||||
|
||||
const camera = await runStep('camera', signal, async () => {
|
||||
const track = await createLocalVideoTrack()
|
||||
const settings = track.mediaStreamTrack.getSettings()
|
||||
const label = track.mediaStreamTrack.label || ''
|
||||
// Released immediately, like the microphone probe: the capture
|
||||
// indicator must not stay on between this check and publishVideo.
|
||||
track.stop()
|
||||
return {
|
||||
summary: label,
|
||||
data: {
|
||||
label,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
},
|
||||
}
|
||||
})
|
||||
if (signal.aborted) return
|
||||
if (camera.state === 'failed' && isPermissionError(camera.error)) {
|
||||
openPermissionsDialog('videoinput')
|
||||
}
|
||||
|
||||
await runStep('devices', signal, async () => {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
return {
|
||||
summary: String(devices.length),
|
||||
data: groupDevicesByKind(devices),
|
||||
}
|
||||
})
|
||||
if (signal.aborted) return
|
||||
|
||||
let checker: ConnectionCheck
|
||||
try {
|
||||
const { livekit } = await fetchConnectionTestDetails()
|
||||
if (signal.aborted) return
|
||||
checker = new ConnectionCheck(livekit.url, livekit.token)
|
||||
} catch (error) {
|
||||
if (signal.aborted) return
|
||||
skipSteps(
|
||||
LIVEKIT_STEP_IDS,
|
||||
getErrorMessage(error, 'Failed to fetch test token')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// LiveKit's ConnectionCheck exposes no cancellation: each check owns its
|
||||
// own room and disconnects it when it settles. The best we can do is
|
||||
// never start the next one once the run has been aborted (runStep
|
||||
// short-circuits on `signal.aborted`).
|
||||
await runStep('websocket', signal, async () =>
|
||||
fromCheckInfo(await checker.checkWebsocket())
|
||||
)
|
||||
await runStep('webrtc', signal, async () =>
|
||||
fromCheckInfo(await checker.checkWebRTC())
|
||||
)
|
||||
await runStep('turn', signal, async () =>
|
||||
fromCheckInfo(await checker.checkTURN())
|
||||
)
|
||||
await runStep('reconnect', signal, async () =>
|
||||
fromCheckInfo(await checker.checkReconnect())
|
||||
)
|
||||
await runStep('selectedCandidate', signal, async () =>
|
||||
fromCheckInfo(await checker.createAndRunCheck(SelectedCandidateCheck))
|
||||
)
|
||||
|
||||
if (microphone.state !== 'success') {
|
||||
skipSteps(['publishAudio'], 'Microphone permission required')
|
||||
} else {
|
||||
await runStep('publishAudio', signal, async () =>
|
||||
fromCheckInfo(await checker.checkPublishAudio())
|
||||
)
|
||||
}
|
||||
|
||||
if (camera.state !== 'success') {
|
||||
skipSteps(['publishVideo'], 'Camera permission required')
|
||||
} else {
|
||||
await runStep('publishVideo', signal, async () =>
|
||||
fromCheckInfo(await checker.checkPublishVideo())
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
}, [runStep, skipSteps])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
setSteps(createInitialSteps())
|
||||
setIsRunning(false)
|
||||
}, [])
|
||||
|
||||
// Leaving the page mid-run must stop the pending checks rather than let them
|
||||
// keep a LiveKit session open behind an unmounted component.
|
||||
useEffect(
|
||||
() => () => {
|
||||
abortRef.current?.abort()
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return {
|
||||
steps,
|
||||
isRunning,
|
||||
runTest,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiDownload2Line,
|
||||
RiErrorWarningLine,
|
||||
RiPlayLine,
|
||||
} from '@remixicon/react'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Button } from '@/primitives'
|
||||
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 { ConnectionTestStepRow } from '../components/ConnectionTestStepRow'
|
||||
import { ConnectionTestSummary } from '../components/ConnectionTestSummary'
|
||||
import { CONNECTION_TEST_GROUPS, summarizeSteps } from '../types'
|
||||
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
|
||||
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
|
||||
|
||||
const sectionClass = css({
|
||||
width: '100%',
|
||||
borderTop: '2px solid {colors.greyscale.900}',
|
||||
paddingTop: '1rem',
|
||||
})
|
||||
|
||||
const sectionTitleClass = css({
|
||||
textStyle: 'h2',
|
||||
color: 'greyscale.1000',
|
||||
margin: 0,
|
||||
})
|
||||
|
||||
const rowsClass = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
marginTop: '0.75rem',
|
||||
})
|
||||
|
||||
const helpClass = css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.5rem',
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
border: '1px solid {colors.greyscale.200}',
|
||||
backgroundColor: 'white',
|
||||
padding: '0.75rem 1rem',
|
||||
textStyle: 'sm',
|
||||
color: 'greyscale.800',
|
||||
})
|
||||
|
||||
const helpIconClass = css({
|
||||
color: 'danger.600',
|
||||
flexShrink: 0,
|
||||
marginTop: '2px',
|
||||
})
|
||||
|
||||
const ConnectionTest = () => {
|
||||
const { data, isLoading } = useConfig()
|
||||
const { t } = useTranslation('connectionTest')
|
||||
const { steps, isRunning, runTest, reset } = useConnectionTestRunner()
|
||||
|
||||
const stats = useMemo(() => summarizeSteps(steps), [steps])
|
||||
const stepsById = useMemo(
|
||||
() => new Map(steps.map((step) => [step.id, step] as const)),
|
||||
[steps]
|
||||
)
|
||||
const isPublishVideoRunning =
|
||||
stepsById.get('publishVideo')?.status === 'running'
|
||||
|
||||
// LiveKit appends a bare <video> to document.body during publishVideo.
|
||||
// Keep it in the DOM (so the frame check still works) but hide it visually.
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle(
|
||||
HIDE_LIVEKIT_VIDEO_CLASS,
|
||||
isPublishVideoRunning
|
||||
)
|
||||
return () => {
|
||||
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
|
||||
}
|
||||
}, [isPublishVideoRunning])
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for config to load, otherwise we'd redirect off a page
|
||||
// that's actually enabled.
|
||||
if (!isLoading && !data?.diagnostics?.connection_test_enabled) {
|
||||
navigateTo('home', undefined, { replace: true })
|
||||
}
|
||||
}, [isLoading, data])
|
||||
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<Permissions />
|
||||
<CenteredContent withBackButton>
|
||||
<Center>
|
||||
<VStack gap="1.5rem" maxWidth="40rem" width="100%">
|
||||
<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.
|
||||
<Button
|
||||
variant="secondary"
|
||||
onPress={reset}
|
||||
icon={<RiCloseLine size={18} aria-hidden="true" />}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={runTest}
|
||||
icon={<RiPlayLine size={18} aria-hidden="true" />}
|
||||
>
|
||||
{stats.hasStarted ? t('runAgain') : t('runTest')}
|
||||
</Button>
|
||||
)}
|
||||
{stats.hasStarted && !isRunning && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onPress={() => downloadConnectionTestReport(steps)}
|
||||
icon={<RiDownload2Line size={18} aria-hidden="true" />}
|
||||
>
|
||||
{t('downloadReport')}
|
||||
</Button>
|
||||
)}
|
||||
</ConnectionTestSummary>
|
||||
|
||||
{stats.hasStarted &&
|
||||
CONNECTION_TEST_GROUPS.map((group) => (
|
||||
<section key={group.id} className={sectionClass}>
|
||||
<h2 className={sectionTitleClass}>
|
||||
{t(`groups.${group.id}`)}
|
||||
</h2>
|
||||
<div className={rowsClass}>
|
||||
{group.steps.map((id) => {
|
||||
const step = stepsById.get(id)
|
||||
return step ? (
|
||||
<ConnectionTestStepRow key={id} step={step} />
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{stats.failed > 0 && !isRunning && (
|
||||
<p className={helpClass}>
|
||||
<RiErrorWarningLine
|
||||
size={18}
|
||||
aria-hidden="true"
|
||||
className={helpIconClass}
|
||||
/>
|
||||
{t('help.firewall')}
|
||||
</p>
|
||||
)}
|
||||
</VStack>
|
||||
</Center>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConnectionTest
|
||||
@@ -0,0 +1,103 @@
|
||||
export type ConnectionTestStepId =
|
||||
| 'browser'
|
||||
| 'microphone'
|
||||
| 'camera'
|
||||
| 'devices'
|
||||
| 'websocket'
|
||||
| 'webrtc'
|
||||
| 'turn'
|
||||
| 'reconnect'
|
||||
| 'selectedCandidate'
|
||||
| 'publishAudio'
|
||||
| 'publishVideo'
|
||||
|
||||
export type ConnectionTestStepStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
|
||||
export type ConnectionTestLog = {
|
||||
level: 'info' | 'warning' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ConnectionTestStepResult = {
|
||||
id: ConnectionTestStepId
|
||||
status: ConnectionTestStepStatus
|
||||
summary?: string
|
||||
logs?: ConnectionTestLog[]
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ConnectionTestGroupId = 'local' | 'network'
|
||||
|
||||
/** Display order: everything local first, then everything that leaves the machine. */
|
||||
export const CONNECTION_TEST_GROUPS: ReadonlyArray<{
|
||||
id: ConnectionTestGroupId
|
||||
steps: ReadonlyArray<ConnectionTestStepId>
|
||||
}> = [
|
||||
{ id: 'local', steps: ['browser', 'microphone', 'camera', 'devices'] },
|
||||
{
|
||||
id: 'network',
|
||||
steps: [
|
||||
'websocket',
|
||||
'webrtc',
|
||||
'turn',
|
||||
'reconnect',
|
||||
'selectedCandidate',
|
||||
'publishAudio',
|
||||
'publishVideo',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] =
|
||||
CONNECTION_TEST_GROUPS.flatMap((group) => [...group.steps])
|
||||
|
||||
export const createInitialSteps = (): ConnectionTestStepResult[] =>
|
||||
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
|
||||
|
||||
export type ConnectionTestStats = {
|
||||
total: number
|
||||
settled: number
|
||||
passed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
hasStarted: boolean
|
||||
progress: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Single pass over the steps: the page needs half a dozen derived booleans and
|
||||
* counters, and scanning the array once per render beats one `.some()` per flag.
|
||||
*/
|
||||
export const summarizeSteps = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
): ConnectionTestStats => {
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
let skipped = 0
|
||||
let pending = 0
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.status === 'success') passed += 1
|
||||
else if (step.status === 'failed') failed += 1
|
||||
else if (step.status === 'skipped') skipped += 1
|
||||
else if (step.status === 'pending') pending += 1
|
||||
}
|
||||
|
||||
const total = steps.length
|
||||
const settled = passed + failed + skipped
|
||||
|
||||
return {
|
||||
total,
|
||||
settled,
|
||||
passed,
|
||||
failed,
|
||||
skipped,
|
||||
hasStarted: pending < total,
|
||||
progress: total === 0 ? 0 : Math.round((settled / total) * 100),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ConnectionTestStepResult } from '../types'
|
||||
|
||||
export type ConnectionTestReport = {
|
||||
generatedAt: string
|
||||
userAgent: string
|
||||
steps: Record<
|
||||
string,
|
||||
{
|
||||
status: ConnectionTestStepResult['status']
|
||||
summary?: string
|
||||
logs?: ConnectionTestStepResult['logs']
|
||||
data?: ConnectionTestStepResult['data']
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export const buildConnectionTestReport = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
): ConnectionTestReport => ({
|
||||
generatedAt: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
steps: Object.fromEntries(
|
||||
steps.map(({ id, status, summary, logs, data }) => [
|
||||
id,
|
||||
{
|
||||
status,
|
||||
...(summary !== undefined ? { summary } : {}),
|
||||
...(logs?.length ? { logs } : {}),
|
||||
...(data !== undefined ? { data } : {}),
|
||||
},
|
||||
])
|
||||
),
|
||||
})
|
||||
|
||||
export const downloadConnectionTestReport = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
) => {
|
||||
const report = buildConnectionTestReport(steps)
|
||||
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
|
||||
const blob = new Blob([JSON.stringify(report, null, 2)], {
|
||||
type: 'application/json',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `connection-test-${timestamp}.json`
|
||||
// Firefox only follows the click when the anchor is in the document, and
|
||||
// revoking the URL in the same tick cancels the download in some browsers.
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { css } from '@/styled-system/css'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -160,7 +161,9 @@ const Home = () => {
|
||||
window.location.replace(data.external_home_url)
|
||||
} catch (error) {
|
||||
setRedirectFailed(true)
|
||||
console.error('Site is not reachable:', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'Site is not reachable:',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NotificationDuration } from './NotificationDuration'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import type { NotificationPayload } from './NotificationPayload'
|
||||
import type { RecordingMode } from '@/features/recording'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const notifyAutoMutedOnJoin = () => {
|
||||
toastQueue.add(
|
||||
@@ -55,7 +56,9 @@ export const decodeNotificationDataReceived = (
|
||||
return parsed as NotificationPayload
|
||||
} catch (error) {
|
||||
// Handle errors appropriately for your application
|
||||
console.error('Failed to decode notification payload:', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'Failed to decode notification payload:',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useLowerHandParticipant } from './lowerHandParticipant'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useLowerHandParticipants = () => {
|
||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||
@@ -11,7 +12,9 @@ export const useLowerHandParticipants = () => {
|
||||
)
|
||||
return Promise.all(promises)
|
||||
} catch (error) {
|
||||
console.error('An error occurred while lowering hands :', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'An error occurred while lowering hands :',
|
||||
})
|
||||
throw new Error('An error occurred while lowering hands.', {
|
||||
cause: error,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useParticipantRole = () => {
|
||||
const data = useRoomData()
|
||||
@@ -22,8 +23,11 @@ export const useParticipantRole = () => {
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
reportError(
|
||||
'generic_failure',
|
||||
new Error(
|
||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} 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
|
||||
|
||||
@@ -87,7 +88,7 @@ export const useWaitingParticipants = () => {
|
||||
|
||||
await refetchWaiting()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
reportError('generic_failure', e)
|
||||
setListEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useEffect, useMemo } from 'react'
|
||||
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
|
||||
|
||||
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
|
||||
const pipStoreSnap = useSnapshot(documentPictureInPictureStore, {
|
||||
sync: true,
|
||||
})
|
||||
|
||||
const container = useMemo(() => {
|
||||
return pipStoreSnap?.window?.document.getElementById('root')
|
||||
@@ -19,7 +21,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!container) return null
|
||||
if (!container || !container.isConnected) return null
|
||||
|
||||
return createPortal(
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ref, useSnapshot } from 'valtio'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const IS_PIP_SUPPORTED =
|
||||
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
|
||||
@@ -59,21 +61,29 @@ export const usePictureInPicture = () => {
|
||||
if (!IS_PIP_SUPPORTED) return null
|
||||
if (isOpen) return null
|
||||
|
||||
let pipWindow: Window
|
||||
try {
|
||||
const pipWindow =
|
||||
pipWindow =
|
||||
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).documentPictureInPicture.requestWindow({
|
||||
width,
|
||||
height,
|
||||
})
|
||||
} catch {
|
||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
initializeTitleAndLanguage(pipWindow, t('title'))
|
||||
initializePortalContainer(pipWindow)
|
||||
syncStyles(pipWindow)
|
||||
|
||||
const cleanUp = () => {
|
||||
if (documentPictureInPictureStore.window === pipWindow) {
|
||||
documentPictureInPictureStore.window = null
|
||||
flushSync(() => {
|
||||
documentPictureInPictureStore.window = null
|
||||
})
|
||||
}
|
||||
}
|
||||
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
|
||||
@@ -82,8 +92,10 @@ export const usePictureInPicture = () => {
|
||||
})
|
||||
documentPictureInPictureStore.window = ref(pipWindow)
|
||||
} catch (error) {
|
||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||
console.error('Failed to open Picture-in-Picture window', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'pip_init_failure',
|
||||
})
|
||||
pipWindow.close()
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
notifyRecordingSaveInProgress,
|
||||
useNotifyParticipants,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { NoAccessView } from './NoAccessView'
|
||||
import { ControlsButton } from './ControlsButton'
|
||||
@@ -29,6 +28,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { LimitDescription } from './LimitDescription'
|
||||
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const ScreenRecordingSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
@@ -63,7 +63,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingRequested,
|
||||
})
|
||||
posthog.capture('screen-recording-requested', {})
|
||||
captureEvent('screen-recording-requested', {})
|
||||
}
|
||||
|
||||
const handleScreenRecording = async () => {
|
||||
@@ -100,13 +100,15 @@ export const ScreenRecordingSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStarted,
|
||||
})
|
||||
posthog.capture('screen-recording-started', {
|
||||
captureEvent('screen-recording-started', {
|
||||
includeTranscript: includeTranscript,
|
||||
language: selectedLanguageKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle recording:', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'Failed to handle recording:',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
useNotifyParticipants,
|
||||
notifyRecordingSaveInProgress,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||
@@ -35,6 +34,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { LimitDescription } from './LimitDescription'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const TranscriptSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionRequested,
|
||||
})
|
||||
posthog.capture('transcript-requested', {})
|
||||
captureEvent('transcript-requested', {})
|
||||
}
|
||||
|
||||
const handleTranscript = async () => {
|
||||
@@ -121,13 +121,15 @@ export const TranscriptSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionStarted,
|
||||
})
|
||||
posthog.capture('transcript-started', {
|
||||
captureEvent('transcript-started', {
|
||||
includeScreenRecording: includeScreenRecording,
|
||||
language: selectedLanguageKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'Failed to handle transcript:',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRoomInfo } from '@livekit/components-react'
|
||||
import { useMemo } from 'react'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useRoomMetadata = () => {
|
||||
const { metadata } = useRoomInfo()
|
||||
@@ -8,7 +9,9 @@ export const useRoomMetadata = () => {
|
||||
try {
|
||||
return JSON.parse(metadata)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse room metadata:', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'Failed to parse room metadata:',
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -18,6 +18,14 @@ export type RoomConfiguration = {
|
||||
everyone_can_mute?: boolean | null
|
||||
}
|
||||
|
||||
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
||||
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
|
||||
|
||||
export type ApiResourceAccess = {
|
||||
id: string
|
||||
role: ParticipantRole
|
||||
}
|
||||
|
||||
export type ApiRoom = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -27,7 +35,12 @@ export type ApiRoom = {
|
||||
access_level: ApiAccessLevel
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: RoomConfiguration
|
||||
/**
|
||||
* Only present in the API response when the requesting user is an
|
||||
* administrator or owner of the room (see RoomSerializer.to_representation
|
||||
* in the backend). Its presence can therefore be used to detect
|
||||
* administrability outside of a LiveKit session, where the room_role
|
||||
* participant attribute is not available.
|
||||
*/
|
||||
accesses?: ApiResourceAccess[]
|
||||
}
|
||||
|
||||
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
||||
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export const fetchRoom = ({
|
||||
roomId,
|
||||
username = '',
|
||||
username,
|
||||
}: {
|
||||
roomId: string
|
||||
username?: string
|
||||
}) => {
|
||||
return fetchApi<ApiRoom>(
|
||||
`/rooms/${roomId}?username=${encodeURIComponent(username)}`
|
||||
)
|
||||
const query = username ? `?username=${encodeURIComponent(username)}` : ''
|
||||
|
||||
return fetchApi<ApiRoom>(`/rooms/${roomId}/${query}`)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fetchApi } from '@/api/fetchApi'
|
||||
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const apiRoomData = useRoomData()
|
||||
@@ -31,7 +32,10 @@ export const useMuteParticipant = () => {
|
||||
|
||||
// Guard against undefined token for non-admin users
|
||||
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
|
||||
console.error('Cannot mute participant: missing auth token')
|
||||
reportError(
|
||||
'participant_mute_api_failure',
|
||||
new Error('Cannot mute participant: missing auth token')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,8 +57,11 @@ export const useMuteParticipant = () => {
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
reportError(
|
||||
'participant_mute_api_failure',
|
||||
new Error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -65,8 +72,11 @@ export const useMuteParticipant = () => {
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||
reportError(
|
||||
'participant_mute_api_failure',
|
||||
new Error(
|
||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useMuteParticipant } from './muteParticipant'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useMuteParticipants = () => {
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
@@ -11,7 +12,9 @@ export const useMuteParticipants = () => {
|
||||
)
|
||||
return Promise.all(promises)
|
||||
} catch (error) {
|
||||
console.error('An error occurred while muting participants :', error)
|
||||
reportError('participant_mute_api_failure', error, {
|
||||
context: 'An error occurred while muting participants :',
|
||||
})
|
||||
throw new Error('An error occurred while muting participants.', {
|
||||
cause: error,
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import { type ApiRoom } from './ApiRoom'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||
import type { ApiError } from '@/api/ApiError'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
|
||||
export type PatchRoomParams = {
|
||||
roomId: string
|
||||
@@ -15,11 +17,25 @@ export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const patchRoomMutationKey = ['patchRoom']
|
||||
|
||||
export function usePatchRoom(
|
||||
options?: UseMutationOptions<ApiRoom, ApiError, PatchRoomParams>
|
||||
) {
|
||||
return useMutation<ApiRoom, ApiError, PatchRoomParams>({
|
||||
mutationKey: patchRoomMutationKey,
|
||||
mutationFn: patchRoom,
|
||||
onSuccess: options?.onSuccess,
|
||||
onMutate: async ({ roomId, room: partialRoom }) => {
|
||||
await queryClient.cancelQueries({ queryKey: [keys.room, roomId] })
|
||||
queryClient.setQueryData<ApiRoom>([keys.room, roomId], (previous) =>
|
||||
previous ? { ...previous, ...partialRoom } : previous
|
||||
)
|
||||
},
|
||||
onSettled: (_data, _error, { roomId }) => {
|
||||
if (queryClient.isMutating({ mutationKey: patchRoomMutationKey }) === 1) {
|
||||
queryClient.invalidateQueries({ queryKey: [keys.room, roomId] })
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Participant, Track } from 'livekit-client'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
type Source = Track.Source
|
||||
|
||||
export const useParticipantPermissions = () => {
|
||||
@@ -32,8 +33,11 @@ export const useParticipantPermissions = () => {
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
reportError(
|
||||
'permissions_api_failure',
|
||||
new Error(
|
||||
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Participant, Track } from 'livekit-client'
|
||||
import { useParticipantPermissions } from './updateParticipantPermissions'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
type Source = Track.Source
|
||||
|
||||
export const useUpdateParticipantsPermissions = () => {
|
||||
@@ -15,7 +16,9 @@ export const useUpdateParticipantsPermissions = () => {
|
||||
)
|
||||
return Promise.all(promises)
|
||||
} catch (error) {
|
||||
console.error('An error occurred while updating permissions :', error)
|
||||
reportError('permissions_api_failure', error, {
|
||||
context: 'An error occurred while updating permissions :',
|
||||
})
|
||||
throw new Error('An error occurred while updating permissions.', {
|
||||
cause: error,
|
||||
})
|
||||
|
||||
@@ -25,8 +25,7 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
@@ -37,6 +36,7 @@ import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
import { userStore } from '@/stores/user'
|
||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -47,7 +47,6 @@ export const Conference = ({
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
}) => {
|
||||
const posthog = usePostHog()
|
||||
const { data: apiConfig } = useConfig()
|
||||
|
||||
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||
@@ -57,8 +56,8 @@ export const Conference = ({
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId, posthog])
|
||||
void captureMediaEvent('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
const fetchKey = [keys.room, roomId]
|
||||
|
||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||
@@ -170,14 +169,6 @@ export const Conference = ({
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
error: MediaDeviceFailure | null
|
||||
kind: MediaDeviceKind | null
|
||||
}>({
|
||||
error: null,
|
||||
kind: null,
|
||||
})
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const hasAutoMutedRef = useRef(false)
|
||||
@@ -235,7 +226,10 @@ export const Conference = ({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
onError={(e) => {
|
||||
posthog.captureException(e)
|
||||
reportError('livekit_room_error', e, {
|
||||
path: 'connect_publish',
|
||||
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
|
||||
})
|
||||
}}
|
||||
onConnected={async () => {
|
||||
if (!apiConfig) return
|
||||
@@ -295,18 +289,10 @@ export const Conference = ({
|
||||
return
|
||||
}
|
||||
}}
|
||||
onMediaDeviceFailure={(e, kind) => {
|
||||
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
|
||||
setMediaDeviceError({ error: e, kind })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<WatchMediaDeviceErrors />
|
||||
<VideoConference />
|
||||
{!isMobile && <InviteDialog mode={mode} />}
|
||||
<MediaDeviceErrorAlert
|
||||
{...mediaDeviceError}
|
||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||
/>
|
||||
<PictureInPictureConference />
|
||||
</LiveKitRoom>
|
||||
</Screen>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||
|
||||
const roomData = useRoomData()
|
||||
const roomUrl = getRouteUrl('room', roomData?.slug)
|
||||
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
|
||||
|
||||
const telephony = useTelephony()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { H } from '@/primitives/H'
|
||||
import { Field } from '@/primitives/Field'
|
||||
import { Form, Text } from '@/primitives'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { saveUsername, userStore } from '@/stores/user'
|
||||
import { fetchRoom } from '../api/fetchRoom'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import { ApiLobbyStatus, type ApiRequestEntry } from '../api/requestEntry'
|
||||
import { useLobby } from '../hooks/useLobby'
|
||||
|
||||
export const Lobby = ({
|
||||
roomId,
|
||||
enterRoom,
|
||||
}: {
|
||||
roomId: string
|
||||
enterRoom: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const { data: configData } = useConfig()
|
||||
const { isLoggedIn, user } = useUser()
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
// Room data strategy:
|
||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
||||
// 3. State is manually updated via queryClient when a waiting participant is accepted
|
||||
// 4. No automatic refetching or revalidation occurs during this period
|
||||
const {
|
||||
data: roomData,
|
||||
error,
|
||||
isError,
|
||||
refetch: refetchRoom,
|
||||
} = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
retry: false,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isError && error?.statusCode == 404) {
|
||||
// The room component will handle the room creation if the user is authenticated
|
||||
enterRoom()
|
||||
}
|
||||
}, [isError, error, enterRoom])
|
||||
|
||||
const handleAccepted = (response: ApiRequestEntry) => {
|
||||
queryClient.setQueryData([keys.room, roomId], {
|
||||
...roomData,
|
||||
livekit: response.livekit,
|
||||
})
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
const { status, startWaiting } = useLobby({
|
||||
roomId,
|
||||
username: username || user?.full_name || 'anonymous',
|
||||
onAccepted: handleAccepted,
|
||||
})
|
||||
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { data } = await refetchRoom()
|
||||
|
||||
if (!data?.livekit) {
|
||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
||||
if (data?.access_level == ApiAccessLevel.TRUSTED) {
|
||||
openLoginHint()
|
||||
}
|
||||
startWaiting()
|
||||
return
|
||||
}
|
||||
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case ApiLobbyStatus.TIMEOUT:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('timeoutInvite.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('timeoutInvite.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
|
||||
case ApiLobbyStatus.DENIED:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('denied.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('denied.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
|
||||
case ApiLobbyStatus.WAITING:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('waiting.title')}
|
||||
</H>
|
||||
<Text
|
||||
as="p"
|
||||
variant="note"
|
||||
className={css({ marginBottom: '1.5rem' })}
|
||||
>
|
||||
{t('waiting.body')}
|
||||
</Text>
|
||||
<Spinner />
|
||||
</VStack>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={t('joinLabel')}
|
||||
submitButtonProps={{
|
||||
fullWidth: true,
|
||||
}}
|
||||
>
|
||||
<VStack marginBottom={1}>
|
||||
<H lvl={1} margin="sm" centered>
|
||||
{t('heading')}
|
||||
</H>
|
||||
{(!isLoggedIn ||
|
||||
configData?.authenticated_users_can_edit_display_name) && (
|
||||
<Field
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username || user?.full_name}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,114 @@
|
||||
import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Dialog, H } from '@/primitives'
|
||||
import { Button, Dialog, H, P } from '@/primitives'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
|
||||
import {
|
||||
closePermissionsDialog,
|
||||
closeSystemPermissionsDialog,
|
||||
permissionsStore,
|
||||
} from '@/stores/permissions'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { injectIconIntoTranslation } from '@/utils/translation'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
import { type OS, getOS } from '@/utils/os'
|
||||
|
||||
type StepsOs = 'macos' | 'windows' | 'android' | 'other'
|
||||
|
||||
const STEPS_OS: Record<OS, StepsOs> = {
|
||||
macos: 'macos',
|
||||
windows: 'windows',
|
||||
android: 'android',
|
||||
linux: 'other',
|
||||
other: 'other',
|
||||
}
|
||||
|
||||
const getSystemSettingsUrl = (os: OS, label: string): string | null => {
|
||||
if (os === 'macos') {
|
||||
if (label === 'camera')
|
||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'
|
||||
if (label === 'microphone')
|
||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
|
||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy'
|
||||
}
|
||||
if (os === 'windows') {
|
||||
if (label === 'camera') return 'ms-settings:privacy-webcam'
|
||||
if (label === 'microphone') return 'ms-settings:privacy-microphone'
|
||||
return 'ms-settings:privacy'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const SystemPermissions = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'systemPermissionDialog' })
|
||||
const permissions = useSnapshot(permissionsStore)
|
||||
const os = useMemo(() => getOS() || 'other', [])
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (permissions.microphoneSystemDenied && permissions.cameraSystemDenied) {
|
||||
return 'cameraAndMicrophone'
|
||||
}
|
||||
if (permissions.cameraSystemDenied) return 'camera'
|
||||
return 'microphone'
|
||||
}, [permissions])
|
||||
|
||||
const isOpen = permissions.isSystemPermissionDialogOpen
|
||||
|
||||
// Auto-close once access works again (the user fixed the OS settings).
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
!permissions.microphoneSystemDenied &&
|
||||
!permissions.cameraSystemDenied
|
||||
) {
|
||||
closeSystemPermissionsDialog()
|
||||
}
|
||||
}, [isOpen, permissions])
|
||||
|
||||
const device = t(`device.${label}`)
|
||||
const settingsUrl = getSystemSettingsUrl(os, label)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t(`heading.${label}`)}
|
||||
onClose={closeSystemPermissionsDialog}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '500px',
|
||||
})}
|
||||
>
|
||||
<H lvl={1}>{t(`heading.${label}`)}</H>
|
||||
<P>{t('intro', { device })}</P>
|
||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<li key={index}>
|
||||
{t(`steps.${STEPS_OS[os] || 'other'}.${index + 1}`, { device })}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{settingsUrl && (
|
||||
<div className={css({ marginTop: '2rem' })}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
window.open(settingsUrl, '_blank')
|
||||
}}
|
||||
>
|
||||
{t('openSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton component - ensures permissions sync runs only once across the app.
|
||||
@@ -65,68 +166,74 @@ export const Permissions = () => {
|
||||
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={permissions.isPermissionDialogOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
})}
|
||||
onClose={closePermissionsDialog}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
md: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
<>
|
||||
<SystemPermissions />
|
||||
<Dialog
|
||||
isOpen={permissions.isPermissionDialogOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
})}
|
||||
onClose={closePermissionsDialog}
|
||||
>
|
||||
<img
|
||||
src="/assets/camera_mic_permission.svg"
|
||||
alt=""
|
||||
className={css({
|
||||
width: '100%',
|
||||
minHeight: '290px',
|
||||
maxWidth: '290px',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '400px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
md: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<H lvl={2}>
|
||||
{t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
<img
|
||||
src="/assets/camera_mic_permission.svg"
|
||||
alt=""
|
||||
className={css({
|
||||
width: '100%',
|
||||
minHeight: '290px',
|
||||
maxWidth: '290px',
|
||||
})}
|
||||
</H>
|
||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||
<li>
|
||||
{isSafari() ? (
|
||||
t('body.openMenu.safari', {
|
||||
appDomain: window.origin.replace('https://', ''),
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{descriptionBeforeIcon}
|
||||
<span
|
||||
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
||||
>
|
||||
<RiEqualizer2Line />
|
||||
</span>
|
||||
{descriptionAfterIcon}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
<li>{t(`body.details.${permissionLabel}`)}</li>
|
||||
</ol>
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '400px',
|
||||
})}
|
||||
>
|
||||
<H lvl={2}>
|
||||
{t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
})}
|
||||
</H>
|
||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||
<li>
|
||||
{isSafari() ? (
|
||||
t('body.openMenu.safari', {
|
||||
appDomain: window.origin.replace('https://', ''),
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{descriptionBeforeIcon}
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line />
|
||||
</span>
|
||||
{descriptionAfterIcon}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
<li>{t(`body.details.${permissionLabel}`)}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Button, H, Input, Text, TextArea } from '@/primitives'
|
||||
import { Button, H, Text, TextArea } from '@/primitives'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { cva } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { CandidateInfo } from '@/stores/connectionObserver'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
const Card = styled('div', {
|
||||
base: {
|
||||
@@ -72,11 +71,9 @@ const labelRecipe = cva({
|
||||
})
|
||||
|
||||
const OpenFeedback = ({
|
||||
posthog,
|
||||
onNext,
|
||||
metadata,
|
||||
}: {
|
||||
posthog: PostHog
|
||||
onNext: () => void
|
||||
metadata?: Record<string, unknown>
|
||||
}) => {
|
||||
@@ -90,7 +87,7 @@ const OpenFeedback = ({
|
||||
|
||||
const onSubmit = () => {
|
||||
try {
|
||||
posthog.capture('open-feedback', {
|
||||
captureEvent('open-feedback', {
|
||||
feedback,
|
||||
...metadata,
|
||||
})
|
||||
@@ -141,12 +138,10 @@ const OpenFeedback = ({
|
||||
}
|
||||
|
||||
const RateQuality = ({
|
||||
posthog,
|
||||
onNext,
|
||||
metadata,
|
||||
maxRating = 5,
|
||||
}: {
|
||||
posthog: PostHog
|
||||
onNext: () => void
|
||||
metadata?: Record<string, unknown>
|
||||
maxRating?: number
|
||||
@@ -160,7 +155,7 @@ const RateQuality = ({
|
||||
|
||||
const onSubmit = () => {
|
||||
try {
|
||||
posthog.capture('quality-rating', {
|
||||
captureEvent('quality-rating', {
|
||||
rating: selectedRating,
|
||||
...metadata,
|
||||
})
|
||||
@@ -243,67 +238,6 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const AuthenticationMessage = ({
|
||||
onNext,
|
||||
posthog,
|
||||
}: {
|
||||
onNext: () => void
|
||||
posthog: PostHog
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'authenticationMessage' })
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const onSubmit = () => {
|
||||
posthog.people.set({ unsafe_email: email })
|
||||
onNext()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
maxWidth: '380px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<H lvl={3}>{t('heading')}</H>
|
||||
<Input
|
||||
id="emailInput"
|
||||
name="email"
|
||||
placeholder={t('placeholder')}
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
/>
|
||||
<VStack gap="0.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
isDisabled={!email}
|
||||
onPress={onSubmit}
|
||||
>
|
||||
{t('submit')}
|
||||
</Button>
|
||||
<Button
|
||||
invisible
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onPress={onNext}
|
||||
>
|
||||
{t('ignore')}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
type RatingMetadata = {
|
||||
room_id?: string
|
||||
pc_publisher?: CandidateInfo
|
||||
@@ -318,12 +252,6 @@ export const Rating = ({
|
||||
metadata: RatingMetadata
|
||||
}) => {
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const posthog = usePostHog()
|
||||
|
||||
const isUserAnonymous = useMemo(() => {
|
||||
return posthog.get_property('$user_state') == 'anonymous'
|
||||
}, [posthog])
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
const sessionId = useMemo(() => crypto.randomUUID(), [])
|
||||
@@ -339,37 +267,14 @@ export const Rating = ({
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
if (step == 0) {
|
||||
return (
|
||||
<RateQuality
|
||||
posthog={posthog}
|
||||
onNext={() => setStep(step + 1)}
|
||||
metadata={metadata}
|
||||
/>
|
||||
)
|
||||
return <RateQuality onNext={() => setStep(step + 1)} metadata={metadata} />
|
||||
}
|
||||
|
||||
if (step == 1) {
|
||||
return (
|
||||
<OpenFeedback
|
||||
posthog={posthog}
|
||||
onNext={() => setStep(step + 1)}
|
||||
metadata={metadata}
|
||||
/>
|
||||
)
|
||||
return <OpenFeedback onNext={() => setStep(step + 1)} metadata={metadata} />
|
||||
}
|
||||
|
||||
if (step == 2) {
|
||||
return isUserAnonymous ? (
|
||||
<AuthenticationMessage
|
||||
posthog={posthog}
|
||||
onNext={() => setStep(step + 1)}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmationMessage onNext={() => setStep(0)} />
|
||||
)
|
||||
}
|
||||
|
||||
if (step == 3) {
|
||||
return <ConfirmationMessage onNext={() => setStep(0)} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { createAudioAnalyser, LocalAudioTrack } from 'livekit-client'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { reportMicSample, silentMicStore } from '@/stores/silentMic'
|
||||
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
||||
import { useIsTrackMuted } from '../livekit/hooks/useIsTrackMuted'
|
||||
|
||||
// A live microphone always has a noise floor; only a signal pinned to
|
||||
// zero counts as silent (no audio data flowing at all).
|
||||
const SILENT_VOLUME_EPSILON = 0.0001
|
||||
const TICK_MS = 1_000
|
||||
|
||||
type SilentMicContext = 'join' | 'room'
|
||||
|
||||
const ActiveDetector = ({
|
||||
track,
|
||||
context,
|
||||
}: {
|
||||
track: LocalAudioTrack
|
||||
context: SilentMicContext
|
||||
}) => {
|
||||
const isMuted = useIsTrackMuted(track)
|
||||
|
||||
// The interval reads through refs so state updates never re-arm the
|
||||
// timer or the analyser.
|
||||
const mutedRef = useRef(isMuted)
|
||||
mutedRef.current = isMuted
|
||||
const contextRef = useRef(context)
|
||||
contextRef.current = context
|
||||
|
||||
useEffect(() => {
|
||||
let audioAnalyser: ReturnType<typeof createAudioAnalyser>
|
||||
try {
|
||||
audioAnalyser = createAudioAnalyser(track, {
|
||||
fftSize: 256,
|
||||
smoothingTimeConstant: 0.7,
|
||||
})
|
||||
} catch {
|
||||
void captureMediaEvent('silent-mic-analyser-unavailable', {
|
||||
context: contextRef.current,
|
||||
})
|
||||
return
|
||||
}
|
||||
const { analyser, calculateVolume, cleanup } = audioAnalyser
|
||||
|
||||
const tick = () => {
|
||||
// Zero volume is only evidence of silence when audio data is
|
||||
// actually flowing. Skip the sample when:
|
||||
// - the mic is intentionally muted;
|
||||
// - the tab is backgrounded (suspended AudioContext reads as zero);
|
||||
// - the AudioContext is not running yet — Chrome keeps it
|
||||
// 'suspended' until a user gesture on pages loaded without
|
||||
// activation, and a suspended analyser reports zeros for a
|
||||
// perfectly healthy microphone.
|
||||
if (
|
||||
mutedRef.current ||
|
||||
document.visibilityState !== 'visible' ||
|
||||
analyser.context.state !== 'running'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const result = reportMicSample({
|
||||
trackId: track.mediaStreamTrack?.id,
|
||||
silent: calculateVolume() <= SILENT_VOLUME_EPSILON,
|
||||
deltaMs: TICK_MS,
|
||||
})
|
||||
if (result === 'silent-detected') {
|
||||
void captureMediaEvent('silent-mic-detected', {
|
||||
context: contextRef.current,
|
||||
media_stream_track_muted: track.mediaStreamTrack?.muted ?? null,
|
||||
})
|
||||
} else if (result === 'recovered') {
|
||||
void captureMediaEvent('silent-mic-recovered', {
|
||||
context: contextRef.current,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const interval = window.setInterval(tick, TICK_MS)
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
void cleanup()
|
||||
}
|
||||
}, [track])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot silent-mic check (see stores/silentMic.ts). Renders nothing;
|
||||
* mounts the volume watcher only while the check is still undecided so
|
||||
* the analyser goes away as soon as the outcome is known.
|
||||
*/
|
||||
export const SilentMicDetector = ({
|
||||
track,
|
||||
context,
|
||||
}: {
|
||||
track?: LocalAudioTrack
|
||||
context: SilentMicContext
|
||||
}) => {
|
||||
const { status } = useSnapshot(silentMicStore)
|
||||
if ((status !== 'watching' && status !== 'silent') || !track) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ActiveDetector
|
||||
key={track.mediaStreamTrack?.id}
|
||||
track={track}
|
||||
context={context}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Room-side variant: watches the published local microphone track. */
|
||||
export const RoomSilentMicDetector = () => {
|
||||
const { microphoneTrack } = useLocalParticipant()
|
||||
const track =
|
||||
microphoneTrack?.track instanceof LocalAudioTrack
|
||||
? microphoneTrack.track
|
||||
: undefined
|
||||
return <SilentMicDetector track={track} context="room" />
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, Dialog, H, P } from '@/primitives'
|
||||
import {
|
||||
closeSilentMicDialog,
|
||||
discardSilentMicDetection,
|
||||
silentMicStore,
|
||||
} from '@/stores/silentMic'
|
||||
|
||||
/**
|
||||
* Opened from the "!" badge on the microphone toggle when the silent-mic
|
||||
* check tripped (see stores/silentMic.ts). Explains the likely causes
|
||||
* and lets the user opt out of the detection for good.
|
||||
*/
|
||||
export const SilentMicDialog = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'silentMic.dialog' })
|
||||
const { isDialogOpen } = useSnapshot(silentMicStore)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isDialogOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t('title')}
|
||||
onClose={closeSilentMicDialog}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '500px',
|
||||
})}
|
||||
>
|
||||
<H lvl={1}>{t('title')}</H>
|
||||
<P>{t('intro')}</P>
|
||||
<ul className={css({ listStyle: 'disc', paddingLeft: '24px' })}>
|
||||
<li>{t('causes.system')}</li>
|
||||
<li>{t('causes.hardware')}</li>
|
||||
<li>{t('causes.wrongDevice')}</li>
|
||||
</ul>
|
||||
<P>{t('hint')}</P>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<Button variant="primary" size="sm" onPress={closeSilentMicDialog}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onPress={discardSilentMicDetection}
|
||||
>
|
||||
{t('discard')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||
import { useWatchMediaDeviceErrors } from '../livekit/hooks/useWatchMediaDeviceErrors'
|
||||
|
||||
/**
|
||||
* Single place responsible for the room's media device errors — mounts the
|
||||
* watcher and renders the resulting user-facing alert.
|
||||
*/
|
||||
export const WatchMediaDeviceErrors = () => {
|
||||
const { error, kind, clear } = useWatchMediaDeviceErrors()
|
||||
return <MediaDeviceErrorAlert error={error} kind={kind} onClose={clear} />
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
|
||||
|
||||
export function useWatchDeviceAvailability() {
|
||||
useEffect(() => {
|
||||
if (!navigator.mediaDevices) return
|
||||
syncDeviceAvailability()
|
||||
navigator.mediaDevices.addEventListener(
|
||||
'devicechange',
|
||||
syncDeviceAvailability
|
||||
)
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
'devicechange',
|
||||
syncDeviceAvailability
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -1,160 +1,36 @@
|
||||
import { useEffect } from 'react'
|
||||
import { permissionsStore } from '@/stores/permissions'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
|
||||
const POLLING_TIME = 500
|
||||
import { syncPermissions } from '@/stores/permissions'
|
||||
|
||||
export const useWatchPermissions = () => {
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined
|
||||
let intervalId: ReturnType<typeof setTimeout> | undefined
|
||||
let isCancelled = false
|
||||
const sync = () => void syncPermissions()
|
||||
sync()
|
||||
|
||||
const checkPermissions = async () => {
|
||||
try {
|
||||
if (!navigator.permissions) {
|
||||
if (!isCancelled) {
|
||||
permissionsStore.cameraPermission = 'unavailable'
|
||||
permissionsStore.microphonePermission = 'unavailable'
|
||||
}
|
||||
return
|
||||
}
|
||||
navigator.mediaDevices?.addEventListener?.('devicechange', sync)
|
||||
window.addEventListener('focus', sync)
|
||||
|
||||
const [cameraPermission, microphonePermission] = await Promise.all([
|
||||
navigator.permissions.query({ name: 'camera' }),
|
||||
navigator.permissions.query({ name: 'microphone' }),
|
||||
])
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
/**
|
||||
* Safari Permission API Limitation Workaround
|
||||
*
|
||||
* Safari has a known issue where permission change events are not reliably fired
|
||||
* when users interact with permission prompts. This is documented in Apple's forums:
|
||||
* https://developer.apple.com/forums/thread/757353
|
||||
*
|
||||
* The problem:
|
||||
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
|
||||
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
|
||||
* - This leaves the UI in an inconsistent state showing outdated permission status
|
||||
*
|
||||
* The solution:
|
||||
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
|
||||
* - Continue polling until both permissions are no longer in 'prompt' state
|
||||
* - This ensures we catch permission changes even when Safari fails to fire events
|
||||
*
|
||||
* This polling is Safari-specific and only activates when needed to minimize performance impact.
|
||||
*/
|
||||
if (
|
||||
isSafari() &&
|
||||
(cameraPermission.state === 'prompt' ||
|
||||
microphonePermission.state === 'prompt')
|
||||
) {
|
||||
// Start polling every 1 second if either permission is in 'prompt' state
|
||||
if (!intervalId) {
|
||||
intervalId = setInterval(async () => {
|
||||
try {
|
||||
const [updatedCamera, updatedMicrophone] = await Promise.all([
|
||||
navigator.permissions.query({ name: 'camera' }),
|
||||
navigator.permissions.query({ name: 'microphone' }),
|
||||
])
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
const cameraChanged =
|
||||
permissionsStore.cameraPermission !== updatedCamera.state
|
||||
const microphoneChanged =
|
||||
permissionsStore.microphonePermission !==
|
||||
updatedMicrophone.state
|
||||
|
||||
if (cameraChanged) {
|
||||
permissionsStore.cameraPermission = updatedCamera.state
|
||||
}
|
||||
|
||||
if (microphoneChanged) {
|
||||
permissionsStore.microphonePermission =
|
||||
updatedMicrophone.state
|
||||
}
|
||||
|
||||
if (
|
||||
updatedCamera.state !== 'prompt' &&
|
||||
updatedMicrophone.state !== 'prompt'
|
||||
) {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = undefined
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error('Error polling permissions:', error)
|
||||
}
|
||||
}
|
||||
}, POLLING_TIME)
|
||||
}
|
||||
}
|
||||
|
||||
permissionsStore.cameraPermission = cameraPermission.state
|
||||
permissionsStore.microphonePermission = microphonePermission.state
|
||||
|
||||
const handleCameraChange = (e: Event) => {
|
||||
const target = e.target as PermissionStatus
|
||||
permissionsStore.cameraPermission = target.state
|
||||
|
||||
if (
|
||||
intervalId &&
|
||||
target.state !== 'prompt' &&
|
||||
microphonePermission.state !== 'prompt'
|
||||
) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const handleMicrophoneChange = (e: Event) => {
|
||||
const target = e.target as PermissionStatus
|
||||
permissionsStore.microphonePermission = target.state
|
||||
|
||||
if (
|
||||
intervalId &&
|
||||
target.state !== 'prompt' &&
|
||||
microphonePermission.state !== 'prompt'
|
||||
) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
cameraPermission.addEventListener('change', handleCameraChange)
|
||||
microphonePermission.addEventListener('change', handleMicrophoneChange)
|
||||
|
||||
cleanup = () => {
|
||||
cameraPermission.removeEventListener('change', handleCameraChange)
|
||||
microphonePermission.removeEventListener(
|
||||
'change',
|
||||
handleMicrophoneChange
|
||||
)
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = undefined
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error('Error checking permissions:', error)
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
permissionsStore.isLoading = false
|
||||
}
|
||||
}
|
||||
let statuses: PermissionStatus[] = []
|
||||
let cancelled = false
|
||||
if (navigator.permissions) {
|
||||
Promise.all([
|
||||
navigator.permissions.query({ name: 'camera' as PermissionName }),
|
||||
navigator.permissions.query({ name: 'microphone' as PermissionName }),
|
||||
])
|
||||
.then((results) => {
|
||||
if (cancelled) return
|
||||
statuses = results
|
||||
statuses.forEach((s) => s.addEventListener('change', sync))
|
||||
})
|
||||
.catch(() => {
|
||||
// Query unsupported: devicechange/focus + gUM outcomes cover it.
|
||||
})
|
||||
}
|
||||
checkPermissions()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
cleanup?.()
|
||||
cancelled = true
|
||||
navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
|
||||
window.removeEventListener('focus', sync)
|
||||
statuses.forEach((s) => s.removeEventListener('change', sync))
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
@@ -14,6 +13,7 @@ import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
||||
import { useEffect } from 'react'
|
||||
import { closeSidePanel } from '@/stores/layout'
|
||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
@@ -206,11 +206,7 @@ export const Admin = () => {
|
||||
patchRoom({
|
||||
roomId,
|
||||
room: { access_level: value as ApiAccessLevel },
|
||||
})
|
||||
.then((room) => {
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
})
|
||||
.catch((e) => console.error(e))
|
||||
}).catch((e) => reportError('generic_failure', e))
|
||||
}
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -11,10 +11,10 @@ import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
|
||||
import posthog from 'posthog-js'
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
||||
|
||||
@@ -182,23 +182,23 @@ export const ConnectionObserver = () => {
|
||||
// total session duration from first connect to final disconnect.
|
||||
if (connectionStartTimeRef.current != null) return
|
||||
connectionStartTimeRef.current = Date.now()
|
||||
posthog.capture('connection-event')
|
||||
void captureMediaEvent('connection-event', {})
|
||||
}
|
||||
|
||||
const handleReconnect = () => {
|
||||
posthog.capture('reconnect-event')
|
||||
captureEvent('reconnect-event')
|
||||
}
|
||||
|
||||
const handleReconnected = () => {
|
||||
posthog.capture('reconnected-event')
|
||||
captureEvent('reconnected-event')
|
||||
}
|
||||
|
||||
const handleSignalingConnect = () => {
|
||||
posthog.capture('signaling-connect-event')
|
||||
captureEvent('signaling-connect-event')
|
||||
}
|
||||
|
||||
const handleSignalingReconnect = () => {
|
||||
posthog.capture('signaling-reconnect-event')
|
||||
captureEvent('signaling-reconnect-event')
|
||||
}
|
||||
|
||||
const handleDisconnect = (
|
||||
@@ -206,7 +206,7 @@ export const ConnectionObserver = () => {
|
||||
) => {
|
||||
const connectionEndTime = Date.now()
|
||||
|
||||
posthog.capture('disconnect-event', {
|
||||
captureEvent('disconnect-event', {
|
||||
// Calculate total session duration from first connection to final disconnect
|
||||
// This duration is sensitive to refreshing the page.
|
||||
sessionDuration: connectionStartTimeRef.current
|
||||
|
||||
@@ -14,7 +14,7 @@ export const Info = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||
|
||||
const data = useRoomData()
|
||||
const roomUrl = getRouteUrl('room', data?.slug)
|
||||
const roomUrl = data?.slug ? getRouteUrl('room', data.slug) : ''
|
||||
|
||||
const telephony = useTelephony()
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import type { LocalTrack } from 'livekit-client'
|
||||
import { useSyncTrackDeviceId } from '../hooks/useSyncTrackDeviceId'
|
||||
import {
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
} from '@/stores/userChoices'
|
||||
|
||||
export const SyncDevicePreferences = () => {
|
||||
const { cameraTrack, microphoneTrack } = useLocalParticipant()
|
||||
useSyncTrackDeviceId(
|
||||
cameraTrack?.track as LocalTrack | undefined,
|
||||
saveVideoInputDeviceId
|
||||
)
|
||||
useSyncTrackDeviceId(
|
||||
microphoneTrack?.track as LocalTrack | undefined,
|
||||
saveAudioInputDeviceId
|
||||
)
|
||||
return null
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,4 @@
|
||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
FilesetResolver,
|
||||
ImageSegmenter,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
type ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
} from '.'
|
||||
import { captureEvent } from '@/features/analytics/telemetry.ts'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
const PROCESSING_HEIGHT = 144
|
||||
@@ -100,7 +100,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
await this.initSegmenter()
|
||||
this._initWorker()
|
||||
|
||||
posthog.capture('firefox-blurring-init')
|
||||
captureEvent('firefox-blurring-init', {})
|
||||
}
|
||||
|
||||
_initVirtualBackgroundImage() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
FilesetResolver,
|
||||
FaceLandmarker,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
} from '.'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
const PROCESSING_WIDTH = 256 * 3
|
||||
const PROCESSING_HEIGHT = 144 * 3
|
||||
@@ -101,7 +101,7 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
||||
await this.initFaceLandmarker()
|
||||
this._initWorker()
|
||||
|
||||
posthog.capture('face-landmarks-init')
|
||||
captureEvent('face-landmarks-init', {})
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ProcessorWrapper } from '@livekit/track-processors'
|
||||
import {
|
||||
ProcessorWrapper,
|
||||
supportsBackgroundProcessors,
|
||||
} from '@livekit/track-processors'
|
||||
import type { Track, TrackProcessor } from 'livekit-client'
|
||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||
@@ -10,7 +13,7 @@ export const SELFIE_SEGMENTER_MODEL_PATH =
|
||||
export const FACE_LANDMARKS_MODEL_PATH =
|
||||
'/assets/mediapipe/models/face_landmarker.task'
|
||||
|
||||
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
|
||||
export const MEDIAPIPE_PATH_WASM = `/assets/mediapipe/wasm/${__MEDIAPIPE_VERSION__}`
|
||||
|
||||
export enum ProcessorType {
|
||||
BLUR = 'blur',
|
||||
@@ -34,7 +37,9 @@ export class BackgroundProcessorFactory {
|
||||
}
|
||||
|
||||
static isSupported() {
|
||||
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
|
||||
return (
|
||||
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
|
||||
)
|
||||
}
|
||||
|
||||
static getProcessor(
|
||||
@@ -45,7 +50,7 @@ export class BackgroundProcessorFactory {
|
||||
|
||||
if (!isBlur && !isVirtual) return undefined
|
||||
|
||||
if (ProcessorWrapper.isSupported) {
|
||||
if (supportsBackgroundProcessors()) {
|
||||
return new UnifiedBackgroundTrackProcessor(config)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RiCameraSwitchLine } from '@remixicon/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ButtonProps } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
enum FacingMode {
|
||||
USER = 'user',
|
||||
@@ -103,7 +104,11 @@ export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
|
||||
setActiveMediaDevice(device.deviceId)
|
||||
setFacingMode(target)
|
||||
} else {
|
||||
console.error('Cannot get user device with facingMode ' + target)
|
||||
reportError(
|
||||
'device_switch_failure',
|
||||
new Error('Cannot get user device with facingMode ' + target),
|
||||
{ path: 'switch_device', kind: 'videoinput', facing_mode: target }
|
||||
)
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
+13
-2
@@ -1,8 +1,12 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import {
|
||||
useLocalParticipant,
|
||||
useTrackToggle,
|
||||
UseTrackToggleProps,
|
||||
} from '@livekit/components-react'
|
||||
import { Button, Popover } from '@/primitives'
|
||||
import { RiArrowUpSLine } from '@remixicon/react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { LocalAudioTrack, Track } from 'livekit-client'
|
||||
|
||||
import { ToggleDevice } from './ToggleDevice'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -51,6 +55,12 @@ export const AudioDevicesControl = ({
|
||||
...props,
|
||||
})
|
||||
|
||||
const { microphoneTrack } = useLocalParticipant()
|
||||
const localAudioTrack =
|
||||
microphoneTrack?.track instanceof LocalAudioTrack
|
||||
? microphoneTrack.track
|
||||
: undefined
|
||||
|
||||
const kind = 'audioinput'
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
||||
@@ -111,6 +121,7 @@ export const AudioDevicesControl = ({
|
||||
context="room"
|
||||
kind={kind}
|
||||
id={audioDeviceId}
|
||||
track={localAudioTrack}
|
||||
onSubmit={saveAudioInputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { LocalAudioTrack } from 'livekit-client'
|
||||
import { useTrackVolume } from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Text } from '@/primitives'
|
||||
import { useIsTrackMuted } from '../../../hooks/useIsTrackMuted'
|
||||
|
||||
const StyledContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.75rem 0.25rem',
|
||||
marginTop: '0.5rem',
|
||||
borderTop: '1px solid',
|
||||
minHeight: '2.5rem',
|
||||
},
|
||||
variants: {
|
||||
theme: {
|
||||
light: {
|
||||
borderColor: 'gray.200',
|
||||
color: 'greyscale.600',
|
||||
},
|
||||
dark: {
|
||||
borderColor: 'primaryDark.300',
|
||||
color: 'rgba(255 255 255 / 0.7)',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const StyledGaugeContainer = styled('div', {
|
||||
base: {
|
||||
flexGrow: 1,
|
||||
height: '0.375rem',
|
||||
borderRadius: '0.1875rem',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
variants: {
|
||||
theme: {
|
||||
light: {
|
||||
backgroundColor: 'greyscale.250',
|
||||
},
|
||||
dark: {
|
||||
backgroundColor: 'rgba(255 255 255 / 0.25)',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const StyledGauge = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 'inherit',
|
||||
transformOrigin: 'left center',
|
||||
transform: 'scaleX(0)',
|
||||
transition: 'transform 0.06s linear',
|
||||
},
|
||||
variants: {
|
||||
theme: {
|
||||
light: {
|
||||
backgroundColor: 'primary.500',
|
||||
},
|
||||
dark: {
|
||||
backgroundColor: 'primaryDark.800',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
type AudioLevelGaugeProps = {
|
||||
track?: LocalAudioTrack
|
||||
variant?: Theme
|
||||
}
|
||||
|
||||
const LevelBar = ({
|
||||
track,
|
||||
theme,
|
||||
}: {
|
||||
track: LocalAudioTrack
|
||||
theme: Theme
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const volume = useTrackVolume(track, {
|
||||
fftSize: 256,
|
||||
smoothingTimeConstant: 0.7,
|
||||
})
|
||||
const level = Math.min(1, volume)
|
||||
|
||||
return (
|
||||
<>
|
||||
<RiMicLine size={18} aria-hidden="true" />
|
||||
<StyledGaugeContainer
|
||||
theme={theme}
|
||||
role="img"
|
||||
aria-label={t('audioinput.level')}
|
||||
>
|
||||
<StyledGauge theme={theme} style={{ transform: `scaleX(${level})` }} />
|
||||
</StyledGaugeContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const AudioLevelGauge = ({
|
||||
track,
|
||||
variant = 'light',
|
||||
}: AudioLevelGaugeProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const isMuted = useIsTrackMuted(track)
|
||||
const showMutedHint = !track || isMuted
|
||||
|
||||
return (
|
||||
<StyledContainer theme={variant}>
|
||||
{showMutedHint ? (
|
||||
<>
|
||||
<RiMicOffLine size={18} aria-hidden="true" />
|
||||
<Text variant="bodyXsMedium">{t('audioinput.muteTest')}</Text>
|
||||
</>
|
||||
) : (
|
||||
<LevelBar
|
||||
key={track.mediaStreamTrack?.id}
|
||||
track={track}
|
||||
theme={variant}
|
||||
/>
|
||||
)}
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiVolumeUpLine } from '@remixicon/react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||
|
||||
// Speaker test in the audiooutput menu footer (Meet-style UX). Outputs have
|
||||
// no track: the test plays a bundled file through the selected sink, and
|
||||
// following `sinkId` mid-playback re-routes it live. No permission involved.
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
const BUTTON_VARIANT = {
|
||||
light: 'quaternaryText',
|
||||
dark: 'primaryTextDark',
|
||||
} as const
|
||||
|
||||
const StyledContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
paddingTop: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
borderTop: '1px solid',
|
||||
},
|
||||
variants: {
|
||||
theme: {
|
||||
light: {
|
||||
borderColor: 'gray.200',
|
||||
},
|
||||
dark: {
|
||||
borderColor: 'primaryDark.300',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const StyledButtonContent = styled('span', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 'full',
|
||||
paddingX: '1.625rem',
|
||||
'& > svg': {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type OutputSoundTesterProps = {
|
||||
/** The device the test should play through (the select's current key). */
|
||||
sinkId?: string
|
||||
variant?: Theme
|
||||
}
|
||||
|
||||
export const OutputSoundTester = ({
|
||||
sinkId,
|
||||
variant = 'light',
|
||||
}: OutputSoundTesterProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
const latestSinkIdRef = useRef(sinkId)
|
||||
latestSinkIdRef.current = sinkId
|
||||
|
||||
const stopPlayback = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
setIsPlaying(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sinkId || !canTestAudioOutput()) return
|
||||
audioRef.current?.setSinkId(sinkId).catch(() => {
|
||||
// Re-routing failed (stale or unplugged device): stop the test rather
|
||||
// than keep playing through the previous sink.
|
||||
if (latestSinkIdRef.current === sinkId) {
|
||||
stopPlayback()
|
||||
}
|
||||
})
|
||||
}, [sinkId, stopPlayback])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
return () => audio?.pause()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<StyledContainer theme={variant}>
|
||||
<Button
|
||||
variant={BUTTON_VARIANT[variant]}
|
||||
size="sm"
|
||||
fullWidth
|
||||
isDisabled={isPlaying}
|
||||
onPress={async () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
try {
|
||||
// Confirm routing before starting: a no-op when already routed,
|
||||
// but rejects on a stale device id, so the test never plays
|
||||
// through the wrong sink.
|
||||
if (sinkId && canTestAudioOutput()) {
|
||||
await audio.setSinkId(sinkId)
|
||||
}
|
||||
await audio.play()
|
||||
setIsPlaying(true)
|
||||
} catch {
|
||||
stopPlayback()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StyledButtonContent>
|
||||
<RiVolumeUpLine size={18} aria-hidden />
|
||||
{isPlaying ? t('audiooutput.testing') : t('audiooutput.test')}
|
||||
</StyledButtonContent>
|
||||
</Button>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="sounds/uprise.mp3"
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
+13
-4
@@ -4,8 +4,17 @@ import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const PermissionNeededButton = () => {
|
||||
type PermissionNeededButtonProps = {
|
||||
tooltip?: string
|
||||
onPress?: () => void
|
||||
}
|
||||
|
||||
export const PermissionNeededButton = ({
|
||||
tooltip,
|
||||
onPress,
|
||||
}: PermissionNeededButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
||||
const label = tooltip ?? t('tooltip')
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -17,9 +26,9 @@ export const PermissionNeededButton = () => {
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
aria-label={t('ariaLabel')}
|
||||
tooltip={t('tooltip')}
|
||||
onPress={() => openPermissionsDialog()}
|
||||
aria-label={tooltip ? label : t('ariaLabel')}
|
||||
tooltip={label}
|
||||
onPress={onPress ?? (() => openPermissionsDialog())}
|
||||
variant="permission"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,12 @@ import { useEffect, useMemo } from 'react'
|
||||
import { Select, SelectProps } from '@/primitives/Select'
|
||||
import type { Placement } from '@react-types/overlays'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
|
||||
import type { LocalAudioTrack } from 'livekit-client'
|
||||
import { AudioLevelGauge } from './AudioLevelGauge'
|
||||
import { OutputSoundTester } from './OutputSoundTester'
|
||||
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||
|
||||
type DeviceItems = Array<{ value: string; label: string }>
|
||||
|
||||
@@ -18,6 +23,7 @@ type SelectDeviceProps = {
|
||||
onSubmit?: (id: string) => void
|
||||
kind: MediaDeviceKind
|
||||
context?: 'join' | 'room'
|
||||
track?: LocalAudioTrack
|
||||
}
|
||||
|
||||
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
|
||||
@@ -28,6 +34,7 @@ const SelectDevicePermissions = <T extends string | number>({
|
||||
kind,
|
||||
onSubmit,
|
||||
iconComponent,
|
||||
track,
|
||||
...props
|
||||
}: SelectDevicePermissionsProps<T>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
@@ -74,6 +81,16 @@ const SelectDevicePermissions = <T extends string | number>({
|
||||
await setActiveMediaDevice(key as string)
|
||||
onSubmit?.(key as string)
|
||||
}}
|
||||
menuFooter={
|
||||
kind === 'audioinput' ? (
|
||||
<AudioLevelGauge track={track} variant={props.variant} />
|
||||
) : kind === 'audiooutput' && canTestAudioOutput() ? (
|
||||
<OutputSoundTester
|
||||
sinkId={selectedKey as string}
|
||||
variant={props.variant}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -84,6 +101,7 @@ export const SelectDevice = ({
|
||||
onSubmit,
|
||||
kind,
|
||||
context = 'join',
|
||||
track,
|
||||
}: SelectDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
@@ -96,6 +114,25 @@ export const SelectDevice = ({
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceMissing = useDeviceMissing(kind)
|
||||
|
||||
if (deviceMissing) {
|
||||
return (
|
||||
<Select
|
||||
aria-label={t(`NotFound.title.${kind}`, {
|
||||
keyPrefix: 'mediaErrorDialog',
|
||||
})}
|
||||
label=""
|
||||
isDisabled={true}
|
||||
items={[]}
|
||||
placeholder={t(`NotFound.title.${kind}`, {
|
||||
keyPrefix: 'mediaErrorDialog',
|
||||
})}
|
||||
iconComponent={deviceIcons.select}
|
||||
{...contextProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (cannotUseDevice) {
|
||||
return (
|
||||
@@ -116,6 +153,7 @@ export const SelectDevice = ({
|
||||
id={id}
|
||||
onSubmit={onSubmit}
|
||||
kind={kind}
|
||||
track={track}
|
||||
iconComponent={deviceIcons.select}
|
||||
{...contextProps}
|
||||
/>
|
||||
|
||||
+68
-11
@@ -1,7 +1,7 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PermissionNeededButton } from './PermissionNeededButton'
|
||||
@@ -12,10 +12,16 @@ import {
|
||||
useMaybeRoomContext,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { MediaDeviceFailure } from 'livekit-client'
|
||||
import { MediaDeviceErrorAlert } from '@/features/rooms/components/MediaDeviceErrorAlert'
|
||||
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
import type {
|
||||
@@ -90,9 +96,45 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceMissing = useDeviceMissing(kind)
|
||||
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||
const silentMicWarning =
|
||||
kind === 'audioinput' &&
|
||||
silentMicStatus === 'silent' &&
|
||||
!cannotUseDevice &&
|
||||
!deviceMissing
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const isRequestingPermission = useRef(false)
|
||||
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
|
||||
|
||||
const onPress = async () => {
|
||||
if (!enabled && deviceMissing) {
|
||||
setShowDeviceNotFound(true)
|
||||
return
|
||||
}
|
||||
if (!cannotUseDevice) {
|
||||
toggle()
|
||||
return
|
||||
}
|
||||
if (isRequestingPermission.current) return
|
||||
isRequestingPermission.current = true
|
||||
try {
|
||||
const granted = await requestDevicePermission(
|
||||
kind,
|
||||
context === 'join' ? 'join_preview' : 'room'
|
||||
)
|
||||
if (granted) {
|
||||
toggle()
|
||||
} else {
|
||||
openPermissionsDialog(kind)
|
||||
}
|
||||
} finally {
|
||||
isRequestingPermission.current = false
|
||||
}
|
||||
}
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: deviceShortcut?.id,
|
||||
handler: async () => {
|
||||
@@ -139,7 +181,20 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{cannotUseDevice && <PermissionNeededButton />}
|
||||
{(cannotUseDevice || deviceMissing) && (
|
||||
<PermissionNeededButton
|
||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||
onPress={
|
||||
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{silentMicWarning && (
|
||||
<PermissionNeededButton
|
||||
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
|
||||
onPress={openSilentMicDialog}
|
||||
/>
|
||||
)}
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
isDisabled={isDisabled}
|
||||
@@ -147,23 +202,25 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
isDisabled || cannotUseDevice || !enabled ? errorVariant : variant
|
||||
}
|
||||
shySelected
|
||||
onPress={() => {
|
||||
if (cannotUseDevice) {
|
||||
openPermissionsDialog(kind)
|
||||
}
|
||||
toggle()
|
||||
}}
|
||||
onPress={onPress}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={
|
||||
cannotUseDevice
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
deviceMissing
|
||||
? t(`deviceNotFound.${kind}`)
|
||||
: cannotUseDevice
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
}
|
||||
{...computedToggleButtonProps}
|
||||
{...overrideToggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
<MediaDeviceErrorAlert
|
||||
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
|
||||
kind={kind}
|
||||
onClose={() => setShowDeviceNotFound(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/primitives'
|
||||
import { RiPhoneFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ConnectionState } from 'livekit-client'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const LeaveButton = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
||||
@@ -15,11 +16,11 @@ export const LeaveButton = () => {
|
||||
tooltip={t('leave')}
|
||||
aria-label={t('leave')}
|
||||
onPress={() => {
|
||||
room
|
||||
.disconnect(true)
|
||||
.catch((e) =>
|
||||
console.error('An error occurred while disconnecting:', e)
|
||||
)
|
||||
room.disconnect(true).catch((e) =>
|
||||
reportError('disconnect_failure', e, {
|
||||
context: 'An error occurred while disconnecting:',
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="controls-leave"
|
||||
>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useConfig } from '@/api/useConfig.ts'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
import { Spinner } from '@/primitives/Spinner.tsx'
|
||||
import { userChoicesStore, saveProcessorConfig } from '@/stores/userChoices'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
@@ -238,7 +239,9 @@ export const EffectsConfiguration = ({
|
||||
|
||||
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
reportError('effects_processor_failure', error, {
|
||||
context: 'Error applying effect:',
|
||||
})
|
||||
} finally {
|
||||
// Without setTimeout the DOM is not refreshing when updating the options.
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RiGlassesLine, RiGoblet2Fill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
|
||||
import type { LocalVideoTrack } from 'livekit-client'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export type FunnyEffectsProps = {
|
||||
videoTrack: LocalVideoTrack
|
||||
@@ -55,7 +56,9 @@ export const FunnyEffects = ({
|
||||
await videoTrack.setProcessor(newProcessor)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('could not update processor', e)
|
||||
reportError('effects_processor_failure', e, {
|
||||
context: 'could not update processor',
|
||||
})
|
||||
} finally {
|
||||
onPending(false)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
||||
isMicrophonePrompted,
|
||||
isCameraDenied,
|
||||
isCameraPrompted,
|
||||
microphoneSystemDenied,
|
||||
cameraSystemDenied,
|
||||
} = useSnapshot(permissionsStore)
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -17,9 +19,11 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
||||
switch (kind) {
|
||||
case 'audioinput':
|
||||
case 'audiooutput': // audiooutput uses microphone permissions
|
||||
return isMicrophoneDenied || isMicrophonePrompted
|
||||
return (
|
||||
isMicrophoneDenied || isMicrophonePrompted || microphoneSystemDenied
|
||||
)
|
||||
case 'videoinput':
|
||||
return isCameraDenied || isCameraPrompted
|
||||
return isCameraDenied || isCameraPrompted || cameraSystemDenied
|
||||
|
||||
default:
|
||||
return false
|
||||
@@ -31,5 +35,7 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
||||
isMicrophonePrompted,
|
||||
isCameraDenied,
|
||||
isCameraPrompted,
|
||||
microphoneSystemDenied,
|
||||
cameraSystemDenied,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
const COPY_SUCCESS_TIMEOUT = 3000
|
||||
|
||||
@@ -58,7 +59,9 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setIsCopied(true)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
reportError('clipboard_failure', error, {
|
||||
context: 'copy_room_content',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +70,9 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
||||
await navigator.clipboard.writeText(roomUrl)
|
||||
setIsRoomUrlCopied(true)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
reportError('clipboard_failure', error, {
|
||||
context: 'copy_room_url',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
|
||||
import { permissionsStore } from '@/stores/permissions'
|
||||
|
||||
/**
|
||||
* enumerateDevices() may hide OS/app-blocked devices on Firefox Android.
|
||||
* Only report "missing" when no permission block explains the absence,
|
||||
* so the permission UI takes precedence.
|
||||
*/
|
||||
export const useDeviceMissing = (kind: MediaDeviceKind): boolean => {
|
||||
const { hasCamera, hasMicrophone } = useSnapshot(deviceAvailabilityStore)
|
||||
const {
|
||||
cameraSystemDenied,
|
||||
microphoneSystemDenied,
|
||||
isCameraDenied,
|
||||
isMicrophoneDenied,
|
||||
} = useSnapshot(permissionsStore)
|
||||
|
||||
switch (kind) {
|
||||
case 'videoinput':
|
||||
return !hasCamera && !cameraSystemDenied && !isCameraDenied
|
||||
case 'audioinput':
|
||||
return !hasMicrophone && !microphoneSystemDenied && !isMicrophoneDenied
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { type TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export function useFullScreen({
|
||||
trackRef,
|
||||
@@ -56,7 +57,9 @@ export function useFullScreen({
|
||||
await docEl.msRequestFullscreen()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error entering fullscreen:', error)
|
||||
reportError('fullscreen_failure', error, {
|
||||
context: 'Error entering fullscreen:',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +73,9 @@ export function useFullScreen({
|
||||
await document.msExitFullscreen()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error exiting fullscreen:', error)
|
||||
reportError('fullscreen_failure', error, {
|
||||
context: 'Error exiting fullscreen:',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { type LocalAudioTrack, TrackEvent } from 'livekit-client'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const useIsTrackMuted = (track?: LocalAudioTrack) => {
|
||||
const [isMuted, setIsMuted] = useState(() => track?.isMuted ?? true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!track) {
|
||||
setIsMuted(true)
|
||||
return
|
||||
}
|
||||
setIsMuted(track.isMuted)
|
||||
const onMuted = () => setIsMuted(true)
|
||||
const onUnmuted = () => setIsMuted(false)
|
||||
track.on(TrackEvent.Muted, onMuted)
|
||||
track.on(TrackEvent.Unmuted, onUnmuted)
|
||||
return () => {
|
||||
track.off(TrackEvent.Muted, onMuted)
|
||||
track.off(TrackEvent.Unmuted, onUnmuted)
|
||||
}
|
||||
}, [track])
|
||||
|
||||
return isMuted
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
createLocalAudioTrack,
|
||||
createLocalVideoTrack,
|
||||
type LocalAudioTrack,
|
||||
type LocalVideoTrack,
|
||||
MediaDeviceFailure,
|
||||
TrackEvent,
|
||||
} from 'livekit-client'
|
||||
import { BackgroundProcessorFactory } from '../components/blur'
|
||||
import {
|
||||
classifyPermissionError,
|
||||
isLikelySystemNotFound,
|
||||
isSystemPermissionError,
|
||||
noteGumSuccess,
|
||||
notePermissionDeniedFromGum,
|
||||
noteSystemPermissionDenied,
|
||||
type PermissionKind,
|
||||
} from '@/stores/permissions'
|
||||
import { getOS } from '@/utils/os'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import {
|
||||
saveAudioInputDeviceId,
|
||||
saveAudioInputEnabled,
|
||||
saveVideoInputDeviceId,
|
||||
saveVideoInputEnabled,
|
||||
userChoicesStore,
|
||||
} 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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests camera and microphone once on mount (one combined call → at
|
||||
* most one browser dialog) and releases them immediately. Readiness is
|
||||
* per kind: track acquisition must wait for its own kind to be settled
|
||||
* to avoid a second dialog, but a microphone that stalls or fails (e.g.
|
||||
* OS-level block on Firefox) must not hold the camera hostage — that is
|
||||
* how LiveKit behaves in-room (independent per-kind acquisitions).
|
||||
*/
|
||||
function useWarmupPermissions(): WarmupState {
|
||||
const [state, setState] = useState<WarmupState>({
|
||||
audioReady: false,
|
||||
videoReady: false,
|
||||
})
|
||||
const started = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (started.current) {
|
||||
return
|
||||
}
|
||||
started.current = true
|
||||
|
||||
const bothReady = () => setState({ audioReady: true, videoReady: true })
|
||||
|
||||
const warmup = async () => {
|
||||
try {
|
||||
stopAll(
|
||||
await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
video: true,
|
||||
})
|
||||
)
|
||||
noteGumSuccess()
|
||||
bothReady()
|
||||
} catch (error) {
|
||||
if (
|
||||
MediaDeviceFailure.getFailure(error as Error) ===
|
||||
MediaDeviceFailure.PermissionDenied &&
|
||||
!isSystemPermissionError(error)
|
||||
) {
|
||||
// Retrying after a dismissal would show a second dialog.
|
||||
onMediaPermissionError(error as Error)
|
||||
bothReady()
|
||||
return
|
||||
}
|
||||
// Combined requests fail atomically (e.g. missing webcam fails the
|
||||
// mic too, and an OS-level block on one device fails both) — retry
|
||||
// per kind to know which device is affected; permission is settled
|
||||
// at the browser level, no dialog risk. The retries run in parallel
|
||||
// and settle readiness independently.
|
||||
void navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.then((stream) => {
|
||||
stopAll(stream)
|
||||
noteGumSuccess('microphone')
|
||||
})
|
||||
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
||||
.finally(() =>
|
||||
setState((current) => ({ ...current, audioReady: true }))
|
||||
)
|
||||
void navigator.mediaDevices
|
||||
.getUserMedia({ video: true })
|
||||
.then((stream) => {
|
||||
stopAll(stream)
|
||||
noteGumSuccess('camera')
|
||||
})
|
||||
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
||||
.finally(() =>
|
||||
setState((current) => ({ ...current, videoReady: true }))
|
||||
)
|
||||
}
|
||||
}
|
||||
warmup()
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
||||
ready,
|
||||
enabled,
|
||||
create,
|
||||
permissionKind,
|
||||
onFailure,
|
||||
}: {
|
||||
ready: boolean
|
||||
enabled: boolean
|
||||
create: () => Promise<T>
|
||||
permissionKind: PermissionKind
|
||||
onFailure: () => void
|
||||
}): T | null {
|
||||
const [track, setTrack] = useState<T | null>(null)
|
||||
|
||||
// Acquire.
|
||||
useEffect(() => {
|
||||
if (!ready || !enabled || track) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
create()
|
||||
.then((newTrack) => {
|
||||
noteGumSuccess(permissionKind)
|
||||
if (cancelled) {
|
||||
newTrack.stop()
|
||||
return
|
||||
}
|
||||
setTrack(newTrack)
|
||||
})
|
||||
.catch((error) => {
|
||||
onMediaPermissionError(error as Error, permissionKind)
|
||||
onFailure()
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [ready, enabled, track, create, permissionKind, onFailure])
|
||||
|
||||
// Release on toggle-off so the LED turns off.
|
||||
useEffect(() => {
|
||||
if (!enabled && track) {
|
||||
track.stop()
|
||||
setTrack(null)
|
||||
}
|
||||
}, [enabled, track])
|
||||
|
||||
// Track ended externally (permission revoked, device unplugged):
|
||||
// disable instead of re-acquiring, so no unsolicited dialog.
|
||||
useEffect(() => {
|
||||
if (!track) {
|
||||
return
|
||||
}
|
||||
const handleEnded = () => {
|
||||
setTrack(null)
|
||||
onFailure()
|
||||
}
|
||||
track.on(TrackEvent.Ended, handleEnded)
|
||||
return () => {
|
||||
track.off(TrackEvent.Ended, handleEnded)
|
||||
}
|
||||
}, [track, onFailure])
|
||||
|
||||
// Release on unmount or replacement.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
track?.stop()
|
||||
}
|
||||
}, [track])
|
||||
|
||||
return track
|
||||
}
|
||||
|
||||
export function useJoinTracks(): {
|
||||
audioTrack: LocalAudioTrack | undefined
|
||||
videoTrack: LocalVideoTrack | undefined
|
||||
} {
|
||||
const {
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
} = useSnapshot(userChoicesStore)
|
||||
|
||||
const { audioReady, videoReady } = useWarmupPermissions()
|
||||
|
||||
const createAudio = useCallback(
|
||||
() =>
|
||||
createLocalAudioTrack({
|
||||
deviceId: audioDeviceId,
|
||||
...VOICE_AUDIO_CONSTRAINTS,
|
||||
}),
|
||||
[audioDeviceId]
|
||||
)
|
||||
|
||||
const createVideo = useCallback(
|
||||
() =>
|
||||
createLocalVideoTrack({
|
||||
deviceId: videoDeviceId,
|
||||
processor:
|
||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||
}),
|
||||
[videoDeviceId, processorConfig]
|
||||
)
|
||||
|
||||
const audioTrack = useLocalTrack({
|
||||
ready: audioReady,
|
||||
enabled: audioEnabled,
|
||||
create: createAudio,
|
||||
permissionKind: 'microphone',
|
||||
onFailure: disableAudio,
|
||||
})
|
||||
|
||||
const videoTrack = useLocalTrack({
|
||||
ready: videoReady,
|
||||
enabled: videoEnabled,
|
||||
create: createVideo,
|
||||
permissionKind: 'camera',
|
||||
onFailure: disableVideo,
|
||||
})
|
||||
|
||||
useSyncTrackDeviceId(audioTrack ?? undefined, saveAudioInputDeviceId)
|
||||
useSyncTrackDeviceId(videoTrack ?? undefined, saveVideoInputDeviceId)
|
||||
|
||||
return {
|
||||
audioTrack: audioTrack ?? undefined,
|
||||
videoTrack: videoTrack ?? undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useCallback } from 'react'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const usePermissionsManager = () => {
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
@@ -23,16 +22,16 @@ export const usePermissionsManager = () => {
|
||||
everyone_can_mute: enabled,
|
||||
}
|
||||
|
||||
const room = await patchRoom({
|
||||
await patchRoom({
|
||||
roomId,
|
||||
room: { configuration: newConfiguration },
|
||||
})
|
||||
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
|
||||
return { configuration: newConfiguration }
|
||||
} catch (error) {
|
||||
console.error('Failed to update muting permission:', error)
|
||||
reportError('permissions_api_failure', error, {
|
||||
context: 'Failed to update muting permission:',
|
||||
})
|
||||
return { success: false, error }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { useRemoteParticipants } from '@livekit/components-react'
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
NotificationType,
|
||||
useNotifyParticipants,
|
||||
} from '@/features/notifications'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const updatePublishSources = (
|
||||
currentSources: Source[],
|
||||
@@ -79,13 +78,11 @@ export const usePublishSourcesManager = () => {
|
||||
can_publish_sources: newSources,
|
||||
}
|
||||
|
||||
const room = await patchRoom({
|
||||
await patchRoom({
|
||||
roomId,
|
||||
room: { configuration: newConfiguration },
|
||||
})
|
||||
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
|
||||
await updateParticipantsPermissions(
|
||||
unprivilegedRemoteParticipants,
|
||||
newSources
|
||||
@@ -112,7 +109,9 @@ export const usePublishSourcesManager = () => {
|
||||
|
||||
return { configuration: newConfiguration }
|
||||
} catch (error) {
|
||||
console.error(`Failed to update ${sources}:`, error)
|
||||
reportError('publish_sources_failure', error, {
|
||||
context: `Failed to update ${sources}:`,
|
||||
})
|
||||
return { success: false, error }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import { useMemo } from 'react'
|
||||
import { useRaiseHand } from '@/features/rooms/api/updateRaiseHand'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
type useRaisedHandProps = {
|
||||
participant: Participant
|
||||
@@ -79,9 +80,9 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
try {
|
||||
await raiseHand(!isHandRaised)
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}`
|
||||
)
|
||||
reportError('generic_failure', e, {
|
||||
context: 'toggle_raised_hand',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user