mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-14 12:43:54 +00:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28826bf459 | |||
| 77c5329f8a | |||
| c8ec1c8a9d | |||
| 01e004e272 | |||
| cbfb97eb54 | |||
| ac503b3ae5 | |||
| 52f119db02 | |||
| 387ae17c22 | |||
| 1eb6f0b9e7 | |||
| 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 | |||
| 6c69c3d6e3 | |||
| 7fd4d20ea7 |
@@ -10,6 +10,67 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) introduce performance mode with auto-detection and telemetry
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 🔥(frontend) drop unused vendored ConnectionObserver
|
||||||
|
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 📈(frontend) downgrade unreachable external home URL from error to event
|
||||||
|
- 🐛(frontend) handle 401 responses when syncing user preferences
|
||||||
|
- 🐛(frontend) harden speaker test against missing sinks and play errors
|
||||||
|
- 🐛(frontend) implement hysteresis band for the control bar layout
|
||||||
|
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
|
||||||
|
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
|
||||||
|
|
||||||
|
## [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
|
- ✨(summary) report exception type in failure analytics
|
||||||
- ✨(frontend) add configurable documentation menu item
|
- ✨(frontend) add configurable documentation menu item
|
||||||
- ✨(frontend) allow promoting authenticated participants
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
@@ -17,6 +78,9 @@ and this project adheres to
|
|||||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||||
- ✨(frontend) let users set default configuration for generated links
|
- ✨(frontend) let users set default configuration for generated links
|
||||||
- ✨(frontend) expose media state to external gateways
|
- ✨(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
|
### Changed
|
||||||
|
|
||||||
@@ -42,6 +106,12 @@ and this project adheres to
|
|||||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||||
- 🚸(frontend) show two initials in the Avatar when possible
|
- 🚸(frontend) show two initials in the Avatar when possible
|
||||||
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
|
- 🩹(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
|
## [1.24.0] - 2026-07-21
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ server {
|
|||||||
sub_filter_once off;
|
sub_filter_once off;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ^~ /assets/mediapipe/wasm/ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
}
|
||||||
|
|
||||||
# Serve static files with caching
|
# Serve static files with caching
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
|
|||||||
@@ -104,3 +104,6 @@ APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
|
|||||||
APPLICATION_JWT_SECRET_KEY=devKey
|
APPLICATION_JWT_SECRET_KEY=devKey
|
||||||
APPLICATION_BASE_URL=http://localhost:3000
|
APPLICATION_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Diagnostics
|
||||||
|
CONNECTION_TEST_ENABLED = True
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.24.0"
|
version = "1.26.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.24.0"
|
version = "1.26.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ def get_frontend_configuration(request):
|
|||||||
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||||
},
|
},
|
||||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||||
|
"diagnostics": {"connection_test_enabled": settings.CONNECTION_TEST_ENABLED},
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class FeatureFlag:
|
|||||||
"addons": "ADDONS_ENABLED",
|
"addons": "ADDONS_ENABLED",
|
||||||
"application": "APPLICATION_ENABLED",
|
"application": "APPLICATION_ENABLED",
|
||||||
"roomkit": "ROOMKIT_ENABLED",
|
"roomkit": "ROOMKIT_ENABLED",
|
||||||
|
"connection_test": "CONNECTION_TEST_ENABLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -85,3 +85,15 @@ class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
scope = "roomkit_join"
|
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
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
@@ -27,6 +29,9 @@ from rest_framework import (
|
|||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
)
|
)
|
||||||
|
from rest_framework import (
|
||||||
|
permissions as drf_permissions,
|
||||||
|
)
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
response as drf_response,
|
response as drf_response,
|
||||||
)
|
)
|
||||||
@@ -36,6 +41,7 @@ from rest_framework import (
|
|||||||
from rest_framework.settings import api_settings
|
from rest_framework.settings import api_settings
|
||||||
|
|
||||||
from core import analytics, enums, models, utils
|
from core import analytics, enums, models, utils
|
||||||
|
from core.api import throttling
|
||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
from core.recording.enums import FileExtension
|
||||||
@@ -93,7 +99,9 @@ from core.services.room_roles import (
|
|||||||
RoomRoleService,
|
RoomRoleService,
|
||||||
)
|
)
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
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.tasks.file import process_file_deletion
|
||||||
|
from core.utils import generate_token
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
from ..models import RoomAccessLevel
|
from ..models import RoomAccessLevel
|
||||||
@@ -1563,3 +1571,68 @@ class FileViewSet(
|
|||||||
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
||||||
|
|
||||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
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.conf import settings
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.template.loader import render_to_string
|
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 _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -121,7 +121,7 @@ class NotificationService:
|
|||||||
msg_plain = render_to_string(
|
msg_plain = render_to_string(
|
||||||
"mail/text/screen_recording.txt", personalized_context
|
"mail/text/screen_recording.txt", personalized_context
|
||||||
)
|
)
|
||||||
subject = str(_("Your recording is ready")) # Force translation
|
subject = gettext("Your recording is ready") # Force translation
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_mail(
|
send_mail(
|
||||||
@@ -192,7 +192,7 @@ class NotificationService:
|
|||||||
"""Generate title from context or return default."""
|
"""Generate title from context or return default."""
|
||||||
if recording_datetime is None:
|
if recording_datetime is None:
|
||||||
with override(locale):
|
with override(locale):
|
||||||
return _("Transcription")
|
return gettext("Transcription")
|
||||||
|
|
||||||
dt = recording_datetime
|
dt = recording_datetime
|
||||||
if owner_timezone:
|
if owner_timezone:
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
room_name = data.room.name or data.egress_info.room_name
|
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):
|
if self._filter_regex and not self._filter_regex.search(room_name):
|
||||||
logger.info("Filtered webhook event for room '%s'", room_name)
|
logger.info("Filtered webhook event for room '%s'", room_name)
|
||||||
return
|
return
|
||||||
@@ -228,6 +235,11 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
# 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):
|
def _handle_room_started(self, data):
|
||||||
"""Handle 'room_started' event."""
|
"""Handle 'room_started' event."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import (
|
from livekit.api import (
|
||||||
|
DeleteRoomRequest,
|
||||||
ListRoomsRequest,
|
ListRoomsRequest,
|
||||||
TwirpError,
|
TwirpError,
|
||||||
UpdateRoomMetadataRequest,
|
UpdateRoomMetadataRequest,
|
||||||
@@ -88,3 +89,30 @@ class RoomManagement:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
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
|
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
import smtplib
|
import smtplib
|
||||||
from unittest import mock
|
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(
|
mock_is_feature_flag_enabled.assert_called_once_with(
|
||||||
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
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 returned data with unsupported event type
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = str(uuid.uuid4())
|
||||||
mock_data.event = "unsupported_event"
|
mock_data.event = "unsupported_event"
|
||||||
mock_receive.return_value = mock_data
|
mock_receive.return_value = mock_data
|
||||||
|
|
||||||
@@ -823,3 +824,33 @@ def test_receive_filter_processes_matching_events(
|
|||||||
service.receive(mock_request)
|
service.receive(mock_request)
|
||||||
|
|
||||||
mock_handle_room_started.assert_called_once()
|
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,
|
addons_viewsets.SessionViewSet,
|
||||||
basename="addons_sessions",
|
basename="addons_sessions",
|
||||||
)
|
)
|
||||||
|
router.register(
|
||||||
|
"diagnostics",
|
||||||
|
viewsets.DiagnosticsViewSet,
|
||||||
|
basename="diagnostics",
|
||||||
|
)
|
||||||
|
|
||||||
# - External API
|
# - External API
|
||||||
external_router = SimpleRouter()
|
external_router = SimpleRouter()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import mimetypes
|
|||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
from datetime import timedelta
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -67,6 +68,7 @@ def generate_token( # noqa: PLR0917
|
|||||||
sources: Optional[List[str]] = None,
|
sources: Optional[List[str]] = None,
|
||||||
role: Optional[str] = None,
|
role: Optional[str] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
|
ttl: Optional[timedelta] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a LiveKit access token for a user in a specific room.
|
"""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
|
role (Optional[str]): Room's access role if any
|
||||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||||
used as identity when user.is_anonymous.
|
used as identity when user.is_anonymous.
|
||||||
|
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The LiveKit JWT access token.
|
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()
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|||||||
@@ -354,6 +354,11 @@ class Base(Configuration):
|
|||||||
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
|
"connection_test": values.Value(
|
||||||
|
default="30/minute",
|
||||||
|
environ_name="CONNECTION_TEST_THROTTLE_RATES",
|
||||||
|
environ_prefix=None,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
@@ -660,6 +665,30 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
default=False,
|
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(
|
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
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_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||||
ADDONS_TOKEN_SECRET_KEY = "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):
|
def __init__(self):
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.24.0"
|
version = "1.26.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.24.0"
|
version = "1.26.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ WORKDIR /home/frontend
|
|||||||
ARG VITE_API_BASE_URL
|
ARG VITE_API_BASE_URL
|
||||||
ENV VITE_API_BASE_URL=${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
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ server {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location ^~ /assets/mediapipe/wasm/ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
}
|
||||||
|
|
||||||
# Serve static files with caching
|
# Serve static files with caching
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
|||||||
Generated
+4
-20
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.24.0",
|
"version": "1.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.24.0",
|
"version": "1.26.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
@@ -15,14 +15,13 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@mediapipe/tasks-vision": "0.10.35",
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "26.3.1",
|
"i18next": "26.3.1",
|
||||||
@@ -1049,18 +1048,12 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"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",
|
"version": "0.10.14",
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -4716,15 +4709,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.24.0",
|
"version": "1.26.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -22,14 +22,13 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@mediapipe/tasks-vision": "0.10.35",
|
"@mediapipe/tasks-vision": "0.10.14",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "26.3.1",
|
"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: {
|
subtitle: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
diagnostics: {
|
||||||
|
connection_test_enabled?: boolean
|
||||||
|
}
|
||||||
telephony: {
|
telephony: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
international_phone_number?: string
|
international_phone_number?: string
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
||||||
import React from 'react'
|
import React, { useLayoutEffect, useMemo } from 'react'
|
||||||
|
|
||||||
const avatar = cva({
|
const avatar = cva({
|
||||||
base: {
|
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 => {
|
const getInitials = (name?: string): string => {
|
||||||
if (!name) return ''
|
if (!name) return ''
|
||||||
const words = name.trim().split(/\s+/).filter(Boolean)
|
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||||
if (words.length === 0) return ''
|
if (words.length === 0) return ''
|
||||||
const first = words[0].charAt(0)
|
const first = getFirstGrapheme(words[0])
|
||||||
const second = words.length > 1 ? words[1].charAt(0) : ''
|
const second = words.length > 1 ? getFirstGrapheme(words[1]) : ''
|
||||||
return (first + second).toUpperCase()
|
return (first + second).toLocaleUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
@@ -44,7 +65,37 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
|
|
||||||
export const Avatar = React.memo(
|
export const Avatar = React.memo(
|
||||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
({ 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ backgroundColor: bgColor, ...style }}
|
style={{ backgroundColor: bgColor, ...style }}
|
||||||
@@ -57,16 +108,17 @@ export const Avatar = React.memo(
|
|||||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
|
ref={textRef}
|
||||||
x="50"
|
x="50"
|
||||||
y="50"
|
y="50"
|
||||||
dy="-0.08em"
|
transform={`translate(0 ${offsetY})`}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize="52"
|
fontSize="52"
|
||||||
fontWeight="500"
|
fontWeight="500"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
{initials.toUpperCase()}
|
{initials}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,24 +2,31 @@ import { Button } from '@/primitives'
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||||
|
|
||||||
export const SoundTester = () => {
|
export const SoundTester = () => {
|
||||||
const { t } = useTranslation('settings')
|
const { t } = useTranslation('settings')
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
|
const { devices, activeDeviceId } = useMediaDeviceSelect({
|
||||||
|
kind: 'audiooutput',
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateActiveId = async (deviceId: string) => {
|
if (!canTestAudioOutput() || !activeDeviceId) return
|
||||||
try {
|
if (!devices.some((device) => device.deviceId === activeDeviceId)) return
|
||||||
await audioRef?.current?.setSinkId(deviceId)
|
audioRef.current?.setSinkId(activeDeviceId).catch((error) => {
|
||||||
} catch (error) {
|
if (error instanceof DOMException && error.name === 'NotFoundError') {
|
||||||
console.error(`Error setting sinkId: ${error}`)
|
return
|
||||||
}
|
}
|
||||||
}
|
reportError(
|
||||||
updateActiveId(activeDeviceId)
|
'device_switch_failure',
|
||||||
}, [activeDeviceId])
|
new Error(`Error setting sinkId: ${error}`)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [devices, activeDeviceId])
|
||||||
|
|
||||||
// prevent pausing the sound
|
// prevent pausing the sound
|
||||||
navigator.mediaSession.setActionHandler('pause', function () {})
|
navigator.mediaSession.setActionHandler('pause', function () {})
|
||||||
@@ -28,9 +35,13 @@ export const SoundTester = () => {
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
audioRef?.current?.play()
|
try {
|
||||||
setIsPlaying(true)
|
await audioRef?.current?.play()
|
||||||
|
setIsPlaying(true)
|
||||||
|
} catch {
|
||||||
|
setIsPlaying(false)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
isDisabled={isPlaying}
|
isDisabled={isPlaying}
|
||||||
@@ -44,7 +55,7 @@ export const SoundTester = () => {
|
|||||||
{/* eslint-disable jsx-a11y/media-has-caption */}
|
{/* eslint-disable jsx-a11y/media-has-caption */}
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
src="sounds/uprise.mp3"
|
src="/sounds/uprise.mp3"
|
||||||
onEnded={() => setIsPlaying(false)}
|
onEnded={() => setIsPlaying(false)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { CaptureResult } from 'posthog-js'
|
||||||
|
|
||||||
|
const IGNORED_EXCEPTION_PATTERNS = [
|
||||||
|
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
|
||||||
|
]
|
||||||
|
|
||||||
|
const shouldIgnoreException = (value: unknown): boolean =>
|
||||||
|
typeof value === 'string' &&
|
||||||
|
IGNORED_EXCEPTION_PATTERNS.some((pattern) => pattern.test(value))
|
||||||
|
|
||||||
|
export const filterExceptions = (
|
||||||
|
event: CaptureResult | null
|
||||||
|
): CaptureResult | null => {
|
||||||
|
if (event?.event !== '$exception') return event
|
||||||
|
|
||||||
|
const exceptionList = event.properties?.['$exception_list']
|
||||||
|
const values: unknown[] = Array.isArray(exceptionList)
|
||||||
|
? exceptionList.map((exception) => exception?.value)
|
||||||
|
: []
|
||||||
|
|
||||||
|
values.push(event.properties?.['$exception_message'])
|
||||||
|
|
||||||
|
return values.some(shouldIgnoreException) ? null : event
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
export interface HardwareSnapshot {
|
||||||
|
/** navigator.hardwareConcurrency — logical CPU cores. */
|
||||||
|
cpu_cores: number | null
|
||||||
|
/** navigator.deviceMemory — RAM in GiB, bucketed by the browser (Chromium only). */
|
||||||
|
device_memory_gb: number | null
|
||||||
|
/** performance.memory.jsHeapSizeLimit in MB (Chromium only, non-standard). */
|
||||||
|
js_heap_limit_mb: number | null
|
||||||
|
/** performance.memory.usedJSHeapSize in MB (Chromium only, non-standard). */
|
||||||
|
js_heap_used_mb: number | null
|
||||||
|
/** Battery level 0..1 via navigator.getBattery() (Chromium only). */
|
||||||
|
battery_level: number | null
|
||||||
|
/** Whether the device is plugged in, via navigator.getBattery(). */
|
||||||
|
battery_charging: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATTERY_TIMEOUT_MS = 1_000
|
||||||
|
|
||||||
|
const toMb = (bytes: unknown): number | null =>
|
||||||
|
typeof bytes === 'number' ? Math.round(bytes / (1024 * 1024)) : null
|
||||||
|
|
||||||
|
export const collectHardwareSnapshot = async (): Promise<HardwareSnapshot> => {
|
||||||
|
const snapshot: HardwareSnapshot = {
|
||||||
|
cpu_cores: null,
|
||||||
|
device_memory_gb: null,
|
||||||
|
js_heap_limit_mb: null,
|
||||||
|
js_heap_used_mb: null,
|
||||||
|
battery_level: null,
|
||||||
|
battery_charging: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
snapshot.cpu_cores = navigator.hardwareConcurrency ?? null
|
||||||
|
|
||||||
|
const nav = navigator as Navigator & {
|
||||||
|
deviceMemory?: number
|
||||||
|
userAgentData?: { mobile?: boolean; platform?: string }
|
||||||
|
getBattery?: () => Promise<{ level: number; charging: boolean }>
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.device_memory_gb = nav.deviceMemory ?? null
|
||||||
|
|
||||||
|
const memory = (
|
||||||
|
performance as Performance & {
|
||||||
|
memory?: { jsHeapSizeLimit?: number; usedJSHeapSize?: number }
|
||||||
|
}
|
||||||
|
).memory
|
||||||
|
snapshot.js_heap_limit_mb = toMb(memory?.jsHeapSizeLimit)
|
||||||
|
snapshot.js_heap_used_mb = toMb(memory?.usedJSHeapSize)
|
||||||
|
|
||||||
|
if (typeof nav.getBattery === 'function') {
|
||||||
|
// getBattery can hang on some platforms — don't let it delay the event.
|
||||||
|
const battery = await Promise.race([
|
||||||
|
nav.getBattery(),
|
||||||
|
new Promise<null>((resolve) =>
|
||||||
|
setTimeout(() => resolve(null), BATTERY_TIMEOUT_MS)
|
||||||
|
),
|
||||||
|
]).catch(() => null)
|
||||||
|
if (battery) {
|
||||||
|
snapshot.battery_level = battery.level
|
||||||
|
snapshot.battery_charging = battery.charging
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// telemetry must never break the app
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useLocation } from 'wouter'
|
|
||||||
import { type PostHog } from 'posthog-js'
|
|
||||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
|
import { getPosthog } from '../utils'
|
||||||
let posthog: PostHog | null = null
|
import { filterExceptions } from '../exceptionFilters'
|
||||||
|
|
||||||
const getPosthog = async () => {
|
|
||||||
if (!posthog) posthog = (await import('posthog-js')).default
|
|
||||||
return posthog
|
|
||||||
}
|
|
||||||
|
|
||||||
export const startAnalyticsSession = (data: ApiUser) => {
|
export const startAnalyticsSession = (data: ApiUser) => {
|
||||||
getPosthog().then((ph) => {
|
getPosthog().then((ph) => {
|
||||||
@@ -38,7 +31,6 @@ export const useAnalytics = ({
|
|||||||
flags_api_host,
|
flags_api_host,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
}: useAnalyticsProps) => {
|
}: useAnalyticsProps) => {
|
||||||
const [location] = useLocation()
|
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,6 +41,14 @@ export const useAnalytics = ({
|
|||||||
api_host: host,
|
api_host: host,
|
||||||
flags_api_host: flags_api_host,
|
flags_api_host: flags_api_host,
|
||||||
person_profiles: 'always',
|
person_profiles: 'always',
|
||||||
|
capture_pageview: 'history_change',
|
||||||
|
capture_pageleave: true,
|
||||||
|
capture_exceptions: {
|
||||||
|
capture_unhandled_errors: true,
|
||||||
|
capture_unhandled_rejections: true,
|
||||||
|
capture_console_errors: true,
|
||||||
|
},
|
||||||
|
before_send: filterExceptions,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}, [id, host, flags_api_host, isDisabled])
|
}, [id, host, flags_api_host, isDisabled])
|
||||||
@@ -58,12 +58,5 @@ export const useAnalytics = ({
|
|||||||
startAnalyticsSession(user)
|
startAnalyticsSession(user)
|
||||||
}, [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
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
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'
|
||||||
|
| 'performance_mode_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
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { queryClient } from '@/api/queryClient'
|
|||||||
import { updateUserPreferences } from './updateUserPreferences'
|
import { updateUserPreferences } from './updateUserPreferences'
|
||||||
import { convertToBackendLanguage } from '@/utils/languages'
|
import { convertToBackendLanguage } from '@/utils/languages'
|
||||||
import { useUser } from './useUser'
|
import { useUser } from './useUser'
|
||||||
|
import { ApiError } from '@/api/ApiError.ts'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
|
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
|
||||||
@@ -42,6 +44,11 @@ export const useSyncUserPreferencesWithBackend = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncBrowserPreferencesToBackend()
|
syncBrowserPreferencesToBackend().catch((error) => {
|
||||||
|
if (error instanceof ApiError && error.statusCode === 401) return
|
||||||
|
reportError('generic_failure', error, {
|
||||||
|
context: '[useSyncUserPreferencesWithBackend] Failed to sync:',
|
||||||
|
})
|
||||||
|
})
|
||||||
}, [i18n.language, isLoggedIn, user, mutateAsync])
|
}, [i18n.language, isLoggedIn, user, mutateAsync])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChatRow } from '@/stores/chat'
|
import { ChatRow } from '@/stores/chat'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { formatChatMessageLinks } from '@livekit/components-react'
|
import { formatChatMessageLinks } from '../utils'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { tokenize, createDefaultGrammar } from '@livekit/components-core'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const defaultGrammar = Object.freeze(createDefaultGrammar())
|
||||||
|
|
||||||
|
export function formatChatMessageLinks(message: string): ReactNode {
|
||||||
|
const trimmedMessage = message.replace(/^[\r\n]+|[\r\n]+$/g, '')
|
||||||
|
return tokenize(trimmedMessage, defaultGrammar).map((tok, i) => {
|
||||||
|
if (typeof tok === `string`) {
|
||||||
|
return tok
|
||||||
|
} else {
|
||||||
|
const content = tok.content.toString()
|
||||||
|
const href =
|
||||||
|
tok.type === `url`
|
||||||
|
? /^http(s?):\/\//.test(content)
|
||||||
|
? content
|
||||||
|
: `https://${content}`
|
||||||
|
: `mailto:${content}`
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="lk-chat-link"
|
||||||
|
key={i}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 { useConfig } from '@/api/useConfig'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
@@ -160,7 +161,11 @@ const Home = () => {
|
|||||||
window.location.replace(data.external_home_url)
|
window.location.replace(data.external_home_url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRedirectFailed(true)
|
setRedirectFailed(true)
|
||||||
console.error('Site is not reachable:', error)
|
captureEvent('external-home-unreachable', {
|
||||||
|
error_name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
error_message:
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { useGridLayout } from '../hooks/useGridLayout'
|
|||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||||
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
extends
|
extends
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export enum ToastDuration {
|
|||||||
MEDIUM = 4000,
|
MEDIUM = 4000,
|
||||||
LONG = 5000,
|
LONG = 5000,
|
||||||
EXTRA_LONG = 7000,
|
EXTRA_LONG = 7000,
|
||||||
|
UNDO_WINDOW = 30000,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NotificationDuration = {
|
export const NotificationDuration = {
|
||||||
@@ -15,4 +16,5 @@ export const NotificationDuration = {
|
|||||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||||
ROLE_CHANGED: ToastDuration.LONG,
|
ROLE_CHANGED: ToastDuration.LONG,
|
||||||
|
CPU_CONSTRAINED: ToastDuration.UNDO_WINDOW,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ export enum NotificationType {
|
|||||||
RecordingSaving = 'recordingSaving',
|
RecordingSaving = 'recordingSaving',
|
||||||
PermissionsRemoved = 'permissionsRemoved',
|
PermissionsRemoved = 'permissionsRemoved',
|
||||||
RoleChanged = 'roleChanged',
|
RoleChanged = 'roleChanged',
|
||||||
|
CpuConstrained = 'cpuConstrained',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useToast } from 'react-aria'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
|
||||||
|
import { type ToastProps } from './Toast'
|
||||||
|
import { VStack } from '@/styled-system/jsx'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button, Text } from '@/primitives'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
import { disablePerformanceMode } from '@/stores/performanceMode'
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
|
// todo - make it closable
|
||||||
|
export function ToastCpuConstrained({ state, ...props }: Readonly<ToastProps>) {
|
||||||
|
const { t } = useTranslation('notifications', {
|
||||||
|
keyPrefix: 'cpuConstrained',
|
||||||
|
})
|
||||||
|
const ref = useRef(null)
|
||||||
|
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||||
|
const toast = props.toast
|
||||||
|
|
||||||
|
const handleKeepQuality = () => {
|
||||||
|
captureEvent('cpu-constrained-degradation-cancelled')
|
||||||
|
disablePerformanceMode({ declinedAuto: true })
|
||||||
|
state.close(toast.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
<VStack
|
||||||
|
justify="start"
|
||||||
|
alignItems="self-start"
|
||||||
|
{...contentProps}
|
||||||
|
maxWidth="370px"
|
||||||
|
gap="0.75rem"
|
||||||
|
padding={14}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('message')}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="text"
|
||||||
|
className={css({
|
||||||
|
color: 'primary.300',
|
||||||
|
})}
|
||||||
|
onPress={() => handleKeepQuality()}
|
||||||
|
>
|
||||||
|
{t('keepQuality')}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</StyledToastContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
|||||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||||
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
||||||
|
import { ToastCpuConstrained } from './ToastCpuConstrained'
|
||||||
|
|
||||||
interface ToastRegionProps extends AriaToastRegionProps {
|
interface ToastRegionProps extends AriaToastRegionProps {
|
||||||
state: ToastState<ToastData>
|
state: ToastState<ToastData>
|
||||||
@@ -74,6 +75,9 @@ const renderToast = (
|
|||||||
case NotificationType.RoleChanged:
|
case NotificationType.RoleChanged:
|
||||||
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
||||||
|
|
||||||
|
case NotificationType.CpuConstrained:
|
||||||
|
return <ToastCpuConstrained key={toast.key} toast={toast} state={state} />
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <Toast key={toast.key} toast={toast} state={state} />
|
return <Toast key={toast.key} toast={toast} state={state} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { NotificationType } from '@/features/notifications/NotificationType
|
|||||||
// fixme - handle dynamic audio output changes
|
// fixme - handle dynamic audio output changes
|
||||||
export const useNotificationSound = () => {
|
export const useNotificationSound = () => {
|
||||||
const notificationsSnap = useSnapshot(notificationsStore)
|
const notificationsSnap = useSnapshot(notificationsStore)
|
||||||
const [play] = useSound('./sounds/notifications.mp3', {
|
const [play] = useSound('/sounds/notifications.mp3', {
|
||||||
sprite: {
|
sprite: {
|
||||||
participantJoined: [0, 1150],
|
participantJoined: [0, 1150],
|
||||||
handRaised: [1400, 180],
|
handRaised: [1400, 180],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { NotificationDuration } from './NotificationDuration'
|
|||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import type { NotificationPayload } from './NotificationPayload'
|
import type { NotificationPayload } from './NotificationPayload'
|
||||||
import type { RecordingMode } from '@/features/recording'
|
import type { RecordingMode } from '@/features/recording'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const notifyAutoMutedOnJoin = () => {
|
export const notifyAutoMutedOnJoin = () => {
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
@@ -14,6 +15,15 @@ export const notifyAutoMutedOnJoin = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const notifyCpuConstrained = () => {
|
||||||
|
toastQueue.add(
|
||||||
|
{
|
||||||
|
type: NotificationType.CpuConstrained,
|
||||||
|
},
|
||||||
|
{ timeout: NotificationDuration.CPU_CONSTRAINED }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const showLowerHandToast = (
|
export const showLowerHandToast = (
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -55,7 +65,9 @@ export const decodeNotificationDataReceived = (
|
|||||||
return parsed as NotificationPayload
|
return parsed as NotificationPayload
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle errors appropriately for your application
|
// 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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useLowerHandParticipant } from './lowerHandParticipant'
|
import { useLowerHandParticipant } from './lowerHandParticipant'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const useLowerHandParticipants = () => {
|
export const useLowerHandParticipants = () => {
|
||||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||||
@@ -11,7 +12,9 @@ export const useLowerHandParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} 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.', {
|
throw new Error('An error occurred while lowering hands.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const useParticipantRole = () => {
|
export const useParticipantRole = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -22,8 +23,11 @@ export const useParticipantRole = () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
reportError(
|
||||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
'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'
|
} from '../../participants/api/listWaitingParticipants'
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 1000
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export const useWaitingParticipants = () => {
|
|||||||
|
|
||||||
await refetchWaiting()
|
await refetchWaiting()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
reportError('generic_failure', e)
|
||||||
setListEnabled(true)
|
setListEnabled(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import {
|
||||||
|
LocalTrackPublication,
|
||||||
|
LocalVideoTrack,
|
||||||
|
ParticipantEvent,
|
||||||
|
Track,
|
||||||
|
} from 'livekit-client'
|
||||||
|
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
|
import { collectHardwareSnapshot } from '@/features/analytics/hardware'
|
||||||
|
import { notifyCpuConstrained } from '@/features/notifications/utils'
|
||||||
|
import { isFireFox } from '@/utils/livekit'
|
||||||
|
import {
|
||||||
|
enablePerformanceMode,
|
||||||
|
performanceModeStore,
|
||||||
|
} from '@/stores/performanceMode'
|
||||||
|
|
||||||
|
export const CpuConstrainedObserver = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const degradedTracksRef = useRef(new WeakSet<LocalVideoTrack>())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const localParticipant = room.localParticipant
|
||||||
|
|
||||||
|
const handleCpuConstrained = (
|
||||||
|
track: LocalVideoTrack,
|
||||||
|
publication: LocalTrackPublication
|
||||||
|
) => {
|
||||||
|
const { enabled, userDeclinedAuto } = performanceModeStore
|
||||||
|
|
||||||
|
const shouldDegrade =
|
||||||
|
publication.source === Track.Source.Camera &&
|
||||||
|
!enabled &&
|
||||||
|
!userDeclinedAuto &&
|
||||||
|
!degradedTracksRef.current.has(track)
|
||||||
|
|
||||||
|
void collectHardwareSnapshot().then((hardware) => {
|
||||||
|
captureEvent('cpu-constrained', {
|
||||||
|
firefox: isFireFox(),
|
||||||
|
source: publication.source,
|
||||||
|
degraded: shouldDegrade,
|
||||||
|
trackOptions: publication.options,
|
||||||
|
performance_mode_enabled: enabled,
|
||||||
|
user_declined_auto: userDeclinedAuto,
|
||||||
|
...hardware,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!shouldDegrade) return
|
||||||
|
degradedTracksRef.current.add(track)
|
||||||
|
|
||||||
|
enablePerformanceMode('cpu')
|
||||||
|
notifyCpuConstrained()
|
||||||
|
}
|
||||||
|
|
||||||
|
localParticipant.on(
|
||||||
|
ParticipantEvent.LocalTrackCpuConstrained,
|
||||||
|
handleCpuConstrained
|
||||||
|
)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
localParticipant.off(
|
||||||
|
ParticipantEvent.LocalTrackCpuConstrained,
|
||||||
|
handleCpuConstrained
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import {
|
||||||
|
LocalTrackPublication,
|
||||||
|
LocalVideoTrack,
|
||||||
|
ParticipantEvent,
|
||||||
|
Track,
|
||||||
|
TrackEvent,
|
||||||
|
} from 'livekit-client'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
import {
|
||||||
|
disablePerformanceMode,
|
||||||
|
performanceModeStore,
|
||||||
|
} from '@/stores/performanceMode'
|
||||||
|
import { degradeVideoTrack, restoreVideoTrack } from '../degradation'
|
||||||
|
|
||||||
|
/** Delay to re-apply degradation after restart to avoid racing LiveKit's encoding recompute. */
|
||||||
|
const REAPPLY_AFTER_RESTART_MS = 1_000
|
||||||
|
|
||||||
|
/** Syncs performance mode store state to outbound camera track encoding settings. */
|
||||||
|
export const PerformanceModeController = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { enabled } = useSnapshot(performanceModeStore)
|
||||||
|
|
||||||
|
// Manage degradation application and track lifecycle events
|
||||||
|
useEffect(() => {
|
||||||
|
const localParticipant = room.localParticipant
|
||||||
|
|
||||||
|
const getCameraTrack = () => {
|
||||||
|
const pub = localParticipant.getTrackPublication(Track.Source.Camera)
|
||||||
|
return pub?.track instanceof LocalVideoTrack ? pub.track : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = getCameraTrack()
|
||||||
|
|
||||||
|
// Restore track quality if performance mode is disabled
|
||||||
|
if (!enabled) {
|
||||||
|
if (track) {
|
||||||
|
restoreVideoTrack(track).catch((err) =>
|
||||||
|
reportError('performance_mode_failure', err, { action: 'restore' })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDegradation = (t: LocalVideoTrack, action = 'degrade') => {
|
||||||
|
degradeVideoTrack(t).catch((err) =>
|
||||||
|
reportError('performance_mode_failure', err, { action })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply degradation after track restarts (e.g. device/resolution changes)
|
||||||
|
const watchTrackRestart = (t: LocalVideoTrack) => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>
|
||||||
|
const onRestarted = () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
if (performanceModeStore.enabled) applyDegradation(t, 'reapply')
|
||||||
|
}, REAPPLY_AFTER_RESTART_MS)
|
||||||
|
}
|
||||||
|
t.on(TrackEvent.Restarted, onRestarted)
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
t.off(TrackEvent.Restarted, onRestarted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unwatchRestart: (() => void) | undefined
|
||||||
|
|
||||||
|
if (track) {
|
||||||
|
applyDegradation(track)
|
||||||
|
unwatchRestart = watchTrackRestart(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply degradation to newly published camera tracks
|
||||||
|
const handlePublished = (pub: LocalTrackPublication) => {
|
||||||
|
if (
|
||||||
|
pub.source === Track.Source.Camera &&
|
||||||
|
pub.track instanceof LocalVideoTrack
|
||||||
|
) {
|
||||||
|
unwatchRestart?.()
|
||||||
|
applyDegradation(pub.track)
|
||||||
|
unwatchRestart = watchTrackRestart(pub.track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localParticipant.on(ParticipantEvent.LocalTrackPublished, handlePublished)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
localParticipant.off(
|
||||||
|
ParticipantEvent.LocalTrackPublished,
|
||||||
|
handlePublished
|
||||||
|
)
|
||||||
|
unwatchRestart?.()
|
||||||
|
}
|
||||||
|
}, [room, enabled])
|
||||||
|
|
||||||
|
// Reset auto (CPU-triggered) performance mode on unmount/leave room
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (performanceModeStore.trigger === 'cpu') {
|
||||||
|
disablePerformanceMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { LocalVideoTrack } from 'livekit-client'
|
||||||
|
import { isFireFox } from '@/utils/livekit'
|
||||||
|
|
||||||
|
/** Target degraded encoding for layer 0 (~360p @ 15fps, ≤300kbps). */
|
||||||
|
export const DEGRADED_MAX_HEIGHT = 360
|
||||||
|
export const DEGRADED_MAX_FRAMERATE = 15
|
||||||
|
export const DEGRADED_MAX_BITRATE = 300_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Firefox ignores `active = false` on RTCRtpSender.
|
||||||
|
* We starve higher layers using LiveKit's sentinel workaround values instead.
|
||||||
|
*/
|
||||||
|
const FF_DISABLED_SCALE_DOWN = 4
|
||||||
|
const FF_DISABLED_MAX_BITRATE = 10
|
||||||
|
const FF_DISABLED_MAX_FRAMERATE = 2
|
||||||
|
|
||||||
|
type SavedEncoding = Pick<
|
||||||
|
RTCRtpEncodingParameters,
|
||||||
|
'active' | 'scaleResolutionDownBy' | 'maxBitrate' | 'maxFramerate'
|
||||||
|
>
|
||||||
|
|
||||||
|
/** Pre-degradation encoding snapshots keyed by track to prevent leaks. */
|
||||||
|
const savedEncodingsByTrack = new WeakMap<LocalVideoTrack, SavedEncoding[]>()
|
||||||
|
|
||||||
|
export const isTrackDegraded = (track: LocalVideoTrack) =>
|
||||||
|
savedEncodingsByTrack.has(track)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revertibly degrades local video quality without unpublishing.
|
||||||
|
* Avoids `prioritizePerformance()` because its internal flag disables dynacast permanently.
|
||||||
|
*
|
||||||
|
* - Layer 0: Capped to 360p / 15fps / 300kbps.
|
||||||
|
* - Other layers: Set to `active: false` (or starved on Firefox).
|
||||||
|
*/
|
||||||
|
export const degradeVideoTrack = async (track: LocalVideoTrack) => {
|
||||||
|
const sender = track.sender
|
||||||
|
if (!sender) {
|
||||||
|
throw new Error('sender not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = sender.getParameters()
|
||||||
|
if (!params.encodings || params.encodings.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot once so re-applications don't overwrite pristine encodings
|
||||||
|
if (!savedEncodingsByTrack.has(track)) {
|
||||||
|
savedEncodingsByTrack.set(
|
||||||
|
track,
|
||||||
|
params.encodings.map((e) => ({
|
||||||
|
active: e.active,
|
||||||
|
scaleResolutionDownBy: e.scaleResolutionDownBy,
|
||||||
|
maxBitrate: e.maxBitrate,
|
||||||
|
maxFramerate: e.maxFramerate,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureHeight =
|
||||||
|
track.mediaStreamTrack.getSettings().height ?? DEGRADED_MAX_HEIGHT
|
||||||
|
|
||||||
|
params.encodings = params.encodings.map((encoding, idx) => {
|
||||||
|
if (idx === 0) {
|
||||||
|
return {
|
||||||
|
...encoding,
|
||||||
|
active: true,
|
||||||
|
scaleResolutionDownBy: Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(captureHeight / DEGRADED_MAX_HEIGHT)
|
||||||
|
),
|
||||||
|
maxFramerate: DEGRADED_MAX_FRAMERATE,
|
||||||
|
maxBitrate: Math.min(
|
||||||
|
encoding.maxBitrate ?? DEGRADED_MAX_BITRATE,
|
||||||
|
DEGRADED_MAX_BITRATE
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFireFox()) {
|
||||||
|
const starved: RTCRtpEncodingParameters = {
|
||||||
|
...encoding,
|
||||||
|
// Firefox workaround: active=false prevents LiveKit re-encodes, while starved values limit bitrate
|
||||||
|
active: false,
|
||||||
|
scaleResolutionDownBy: FF_DISABLED_SCALE_DOWN,
|
||||||
|
maxBitrate: FF_DISABLED_MAX_BITRATE,
|
||||||
|
maxFramerate: FF_DISABLED_MAX_FRAMERATE,
|
||||||
|
}
|
||||||
|
// LiveKit legacy property fallback for Firefox
|
||||||
|
;(starved as Record<string, unknown>).maxFrameRate =
|
||||||
|
FF_DISABLED_MAX_FRAMERATE
|
||||||
|
return starved
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...encoding, active: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
await sender.setParameters(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restores encodings captured prior to degradation. */
|
||||||
|
export const restoreVideoTrack = async (track: LocalVideoTrack) => {
|
||||||
|
const saved = savedEncodingsByTrack.get(track)
|
||||||
|
savedEncodingsByTrack.delete(track)
|
||||||
|
|
||||||
|
const sender = track.sender
|
||||||
|
if (!saved || !sender) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = sender.getParameters()
|
||||||
|
if (!params.encodings || params.encodings.length !== saved.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params.encodings = params.encodings.map((encoding, idx) => {
|
||||||
|
const restored: RTCRtpEncodingParameters = {
|
||||||
|
...encoding,
|
||||||
|
...saved[idx],
|
||||||
|
}
|
||||||
|
// Clean up Firefox legacy property if set during degradation
|
||||||
|
;(restored as Record<string, unknown>).maxFrameRate = undefined
|
||||||
|
return restored
|
||||||
|
})
|
||||||
|
|
||||||
|
await sender.setParameters(params)
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import { useEffect, useMemo } from 'react'
|
|||||||
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
|
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
|
||||||
|
|
||||||
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
||||||
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
|
const pipStoreSnap = useSnapshot(documentPictureInPictureStore, {
|
||||||
|
sync: true,
|
||||||
|
})
|
||||||
|
|
||||||
const container = useMemo(() => {
|
const container = useMemo(() => {
|
||||||
return pipStoreSnap?.window?.document.getElementById('root')
|
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(
|
return createPortal(
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ref, useSnapshot } from 'valtio'
|
import { ref, useSnapshot } from 'valtio'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
|
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const IS_PIP_SUPPORTED =
|
export const IS_PIP_SUPPORTED =
|
||||||
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
|
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
|
||||||
@@ -59,21 +61,29 @@ export const usePictureInPicture = () => {
|
|||||||
if (!IS_PIP_SUPPORTED) return null
|
if (!IS_PIP_SUPPORTED) return null
|
||||||
if (isOpen) return null
|
if (isOpen) return null
|
||||||
|
|
||||||
|
let pipWindow: Window
|
||||||
try {
|
try {
|
||||||
const pipWindow =
|
pipWindow =
|
||||||
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(window as any).documentPictureInPicture.requestWindow({
|
(window as any).documentPictureInPicture.requestWindow({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
|
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
initializeTitleAndLanguage(pipWindow, t('title'))
|
initializeTitleAndLanguage(pipWindow, t('title'))
|
||||||
initializePortalContainer(pipWindow)
|
initializePortalContainer(pipWindow)
|
||||||
syncStyles(pipWindow)
|
syncStyles(pipWindow)
|
||||||
|
|
||||||
const cleanUp = () => {
|
const cleanUp = () => {
|
||||||
if (documentPictureInPictureStore.window === pipWindow) {
|
if (documentPictureInPictureStore.window === pipWindow) {
|
||||||
documentPictureInPictureStore.window = null
|
flushSync(() => {
|
||||||
|
documentPictureInPictureStore.window = null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
|
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
|
||||||
@@ -82,8 +92,10 @@ export const usePictureInPicture = () => {
|
|||||||
})
|
})
|
||||||
documentPictureInPictureStore.window = ref(pipWindow)
|
documentPictureInPictureStore.window = ref(pipWindow)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
reportError('generic_failure', error, {
|
||||||
console.error('Failed to open Picture-in-Picture window', error)
|
context: 'pip_init_failure',
|
||||||
|
})
|
||||||
|
pipWindow.close()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ const StyledContainer = styled('div', {
|
|||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
transform: 'translateY(3.25rem)',
|
translate: '0 3.25rem',
|
||||||
transition: 'opacity, transform',
|
transition: 'opacity, translate',
|
||||||
transitionDuration: '0.5s',
|
transitionDuration: '0.5s',
|
||||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
@@ -36,7 +36,7 @@ const StyledContainer = styled('div', {
|
|||||||
isVisible: {
|
isVisible: {
|
||||||
true: {
|
true: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transform: 'translateY(0)',
|
translate: '0 0',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
shouldBeCenteredWithToggleButton,
|
shouldBeCenteredWithToggleButton,
|
||||||
setShouldBeCenteredWithToggleButton,
|
setShouldBeCenteredWithToggleButton,
|
||||||
] = useState(false)
|
] = useState(false)
|
||||||
const [rightOffset, setRightOffset] = useState(0)
|
const [offsetX, setOffsetX] = useState(0)
|
||||||
|
|
||||||
const updateArrows = useCallback(() => {
|
const updateArrows = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
@@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!shouldBeCenteredWithToggleButton || isMobile) {
|
if (!shouldBeCenteredWithToggleButton || isMobile) {
|
||||||
setRightOffset(0)
|
setOffsetX(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
const containerCenterX = containerRect.left + containerRect.width / 2
|
const containerCenterX = containerRect.left + containerRect.width / 2
|
||||||
const shift = toggleCenterX - containerCenterX
|
const shift = toggleCenterX - containerCenterX
|
||||||
if (Math.abs(shift) < 0.5) return
|
if (Math.abs(shift) < 0.5) return
|
||||||
setRightOffset((prev) => prev - shift * 2)
|
setOffsetX((prev) => prev + shift)
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
style={
|
style={
|
||||||
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
||||||
? { marginRight: `${rightOffset}px` }
|
? { transform: `translateX(${offsetX}px)` }
|
||||||
: { margin: '0 15px' }
|
: { margin: '0 15px' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
notifyRecordingSaveInProgress,
|
notifyRecordingSaveInProgress,
|
||||||
useNotifyParticipants,
|
useNotifyParticipants,
|
||||||
} from '@/features/notifications'
|
} from '@/features/notifications'
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { NoAccessView } from './NoAccessView'
|
import { NoAccessView } from './NoAccessView'
|
||||||
import { ControlsButton } from './ControlsButton'
|
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 { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { FeatureFlags } from '@/features/analytics/enums'
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
|
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const ScreenRecordingSidePanel = () => {
|
export const ScreenRecordingSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -63,7 +63,7 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingRequested,
|
type: NotificationType.ScreenRecordingRequested,
|
||||||
})
|
})
|
||||||
posthog.capture('screen-recording-requested', {})
|
captureEvent('screen-recording-requested', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScreenRecording = async () => {
|
const handleScreenRecording = async () => {
|
||||||
@@ -100,13 +100,15 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingStarted,
|
type: NotificationType.ScreenRecordingStarted,
|
||||||
})
|
})
|
||||||
posthog.capture('screen-recording-started', {
|
captureEvent('screen-recording-started', {
|
||||||
includeTranscript: includeTranscript,
|
includeTranscript: includeTranscript,
|
||||||
language: selectedLanguageKey,
|
language: selectedLanguageKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to handle recording:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to handle recording:',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
useNotifyParticipants,
|
useNotifyParticipants,
|
||||||
notifyRecordingSaveInProgress,
|
notifyRecordingSaveInProgress,
|
||||||
} from '@/features/notifications'
|
} from '@/features/notifications'
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { VStack } from '@/styled-system/jsx'
|
import { VStack } from '@/styled-system/jsx'
|
||||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
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 { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
import { openSettingsDialog } from '@/stores/settings'
|
||||||
|
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const TranscriptSidePanel = () => {
|
export const TranscriptSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionRequested,
|
type: NotificationType.TranscriptionRequested,
|
||||||
})
|
})
|
||||||
posthog.capture('transcript-requested', {})
|
captureEvent('transcript-requested', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTranscript = async () => {
|
const handleTranscript = async () => {
|
||||||
@@ -121,13 +121,15 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionStarted,
|
type: NotificationType.TranscriptionStarted,
|
||||||
})
|
})
|
||||||
posthog.capture('transcript-started', {
|
captureEvent('transcript-started', {
|
||||||
includeScreenRecording: includeScreenRecording,
|
includeScreenRecording: includeScreenRecording,
|
||||||
language: selectedLanguageKey,
|
language: selectedLanguageKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 { useRoomInfo } from '@livekit/components-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const useRoomMetadata = () => {
|
export const useRoomMetadata = () => {
|
||||||
const { metadata } = useRoomInfo()
|
const { metadata } = useRoomInfo()
|
||||||
@@ -8,7 +9,9 @@ export const useRoomMetadata = () => {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(metadata)
|
return JSON.parse(metadata)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse room metadata:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to parse room metadata:',
|
||||||
|
})
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export type RoomConfiguration = {
|
|||||||
everyone_can_mute?: boolean | null
|
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 = {
|
export type ApiRoom = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -27,7 +35,12 @@ export type ApiRoom = {
|
|||||||
access_level: ApiAccessLevel
|
access_level: ApiAccessLevel
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
configuration?: RoomConfiguration
|
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 = ({
|
export const fetchRoom = ({
|
||||||
roomId,
|
roomId,
|
||||||
username = '',
|
username,
|
||||||
}: {
|
}: {
|
||||||
roomId: string
|
roomId: string
|
||||||
username?: string
|
username?: string
|
||||||
}) => {
|
}) => {
|
||||||
return fetchApi<ApiRoom>(
|
const query = username ? `?username=${encodeURIComponent(username)}` : ''
|
||||||
`/rooms/${roomId}?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 { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
||||||
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const useMuteParticipant = () => {
|
export const useMuteParticipant = () => {
|
||||||
const apiRoomData = useRoomData()
|
const apiRoomData = useRoomData()
|
||||||
@@ -31,7 +32,10 @@ export const useMuteParticipant = () => {
|
|||||||
|
|
||||||
// Guard against undefined token for non-admin users
|
// Guard against undefined token for non-admin users
|
||||||
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +57,11 @@ export const useMuteParticipant = () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
reportError(
|
||||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
'participant_mute_api_failure',
|
||||||
|
new Error(
|
||||||
|
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -65,8 +72,11 @@ export const useMuteParticipant = () => {
|
|||||||
destinationIdentities: [participant.identity],
|
destinationIdentities: [participant.identity],
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
reportError(
|
||||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
'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 type { Participant } from 'livekit-client'
|
||||||
import { useMuteParticipant } from './muteParticipant'
|
import { useMuteParticipant } from './muteParticipant'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const useMuteParticipants = () => {
|
export const useMuteParticipants = () => {
|
||||||
const { muteParticipant } = useMuteParticipant()
|
const { muteParticipant } = useMuteParticipant()
|
||||||
@@ -11,7 +12,9 @@ export const useMuteParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} 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.', {
|
throw new Error('An error occurred while muting participants.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { type ApiRoom } from './ApiRoom'
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||||
import type { ApiError } from '@/api/ApiError'
|
import type { ApiError } from '@/api/ApiError'
|
||||||
|
import { queryClient } from '@/api/queryClient'
|
||||||
|
import { keys } from '@/api/queryKeys'
|
||||||
|
|
||||||
export type PatchRoomParams = {
|
export type PatchRoomParams = {
|
||||||
roomId: string
|
roomId: string
|
||||||
@@ -15,11 +17,25 @@ export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const patchRoomMutationKey = ['patchRoom']
|
||||||
|
|
||||||
export function usePatchRoom(
|
export function usePatchRoom(
|
||||||
options?: UseMutationOptions<ApiRoom, ApiError, PatchRoomParams>
|
options?: UseMutationOptions<ApiRoom, ApiError, PatchRoomParams>
|
||||||
) {
|
) {
|
||||||
return useMutation<ApiRoom, ApiError, PatchRoomParams>({
|
return useMutation<ApiRoom, ApiError, PatchRoomParams>({
|
||||||
|
mutationKey: patchRoomMutationKey,
|
||||||
mutationFn: patchRoom,
|
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 type { Participant, Track } from 'livekit-client'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
export const useParticipantPermissions = () => {
|
export const useParticipantPermissions = () => {
|
||||||
@@ -32,8 +33,11 @@ export const useParticipantPermissions = () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
reportError(
|
||||||
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
'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 type { Participant, Track } from 'livekit-client'
|
||||||
import { useParticipantPermissions } from './updateParticipantPermissions'
|
import { useParticipantPermissions } from './updateParticipantPermissions'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
export const useUpdateParticipantsPermissions = () => {
|
export const useUpdateParticipantsPermissions = () => {
|
||||||
@@ -15,7 +16,9 @@ export const useUpdateParticipantsPermissions = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} 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.', {
|
throw new Error('An error occurred while updating permissions.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,18 +25,17 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||||
import { LocalUserChoices } from '@/stores/userChoices'
|
import { LocalUserChoices } from '@/stores/userChoices'
|
||||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
import { usePostHog } from 'posthog-js/react'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { isFireFox } from '@/utils/livekit'
|
import { isFireFox } from '@/utils/livekit'
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
|
||||||
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
|
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
|
||||||
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
import { userStore } from '@/stores/user'
|
import { userStore } from '@/stores/user'
|
||||||
|
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -47,7 +46,6 @@ export const Conference = ({
|
|||||||
mode?: 'join' | 'create'
|
mode?: 'join' | 'create'
|
||||||
initialRoomData?: ApiRoom
|
initialRoomData?: ApiRoom
|
||||||
}) => {
|
}) => {
|
||||||
const posthog = usePostHog()
|
|
||||||
const { data: apiConfig } = useConfig()
|
const { data: apiConfig } = useConfig()
|
||||||
|
|
||||||
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||||
@@ -57,8 +55,8 @@ export const Conference = ({
|
|||||||
const { username } = useSnapshot(userStore)
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
posthog.capture('visit-room', { slug: roomId })
|
void captureMediaEvent('visit-room', { slug: roomId })
|
||||||
}, [roomId, posthog])
|
}, [roomId])
|
||||||
const fetchKey = [keys.room, roomId]
|
const fetchKey = [keys.room, roomId]
|
||||||
|
|
||||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||||
@@ -170,14 +168,6 @@ export const Conference = ({
|
|||||||
prepareConnection()
|
prepareConnection()
|
||||||
}, [room, apiConfig, isConnectionWarmedUp])
|
}, [room, apiConfig, isConnectionWarmedUp])
|
||||||
|
|
||||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
|
||||||
error: MediaDeviceFailure | null
|
|
||||||
kind: MediaDeviceKind | null
|
|
||||||
}>({
|
|
||||||
error: null,
|
|
||||||
kind: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const hasAutoMutedRef = useRef(false)
|
const hasAutoMutedRef = useRef(false)
|
||||||
@@ -235,7 +225,10 @@ export const Conference = ({
|
|||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
posthog.captureException(e)
|
reportError('livekit_room_error', e, {
|
||||||
|
path: 'connect_publish',
|
||||||
|
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
onConnected={async () => {
|
onConnected={async () => {
|
||||||
if (!apiConfig) return
|
if (!apiConfig) return
|
||||||
@@ -253,23 +246,8 @@ export const Conference = ({
|
|||||||
onDisconnected={(e) => {
|
onDisconnected={(e) => {
|
||||||
const metadata = {
|
const metadata = {
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
pc_publisher: connectionObserverStore.publisher && {
|
|
||||||
...connectionObserverStore.publisher,
|
|
||||||
},
|
|
||||||
pc_subscriber: connectionObserverStore.subscriber && {
|
|
||||||
...connectionObserverStore.subscriber,
|
|
||||||
},
|
|
||||||
pc_publisher_changes_count:
|
|
||||||
connectionObserverStore.publisherChangesCount,
|
|
||||||
pc_subscriber_changes_count:
|
|
||||||
connectionObserverStore.subscriberChangesCount,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionObserverStore.publisher = null
|
|
||||||
connectionObserverStore.publisherChangesCount = 0
|
|
||||||
connectionObserverStore.subscriber = null
|
|
||||||
connectionObserverStore.subscriberChangesCount = 0
|
|
||||||
|
|
||||||
switch (e) {
|
switch (e) {
|
||||||
case DisconnectReason.CLIENT_INITIATED:
|
case DisconnectReason.CLIENT_INITIATED:
|
||||||
navigateTo(
|
navigateTo(
|
||||||
@@ -295,18 +273,10 @@ export const Conference = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMediaDeviceFailure={(e, kind) => {
|
|
||||||
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
|
|
||||||
setMediaDeviceError({ error: e, kind })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<WatchMediaDeviceErrors />
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{!isMobile && <InviteDialog mode={mode} />}
|
{!isMobile && <InviteDialog mode={mode} />}
|
||||||
<MediaDeviceErrorAlert
|
|
||||||
{...mediaDeviceError}
|
|
||||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
|
||||||
/>
|
|
||||||
<PictureInPictureConference />
|
<PictureInPictureConference />
|
||||||
</LiveKitRoom>
|
</LiveKitRoom>
|
||||||
</Screen>
|
</Screen>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
const roomUrl = getRouteUrl('room', roomData?.slug)
|
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
|
||||||
|
|
||||||
const telephony = useTelephony()
|
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 { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Dialog, H } from '@/primitives'
|
import { Button, Dialog, H, P } from '@/primitives'
|
||||||
import { RiEqualizer2Line } from '@remixicon/react'
|
import { RiEqualizer2Line } from '@remixicon/react'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
|
import {
|
||||||
|
closePermissionsDialog,
|
||||||
|
closeSystemPermissionsDialog,
|
||||||
|
permissionsStore,
|
||||||
|
} from '@/stores/permissions'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { injectIconIntoTranslation } from '@/utils/translation'
|
import { injectIconIntoTranslation } from '@/utils/translation'
|
||||||
import { isSafari } from '@/utils/livekit'
|
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.
|
* 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}`
|
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<>
|
||||||
isOpen={permissions.isPermissionDialogOpen}
|
<SystemPermissions />
|
||||||
role="dialog"
|
<Dialog
|
||||||
type="flex"
|
isOpen={permissions.isPermissionDialogOpen}
|
||||||
title=""
|
role="dialog"
|
||||||
aria-label={t(`heading.${permissionLabel}`, {
|
type="flex"
|
||||||
appTitle,
|
title=""
|
||||||
})}
|
aria-label={t(`heading.${permissionLabel}`, {
|
||||||
onClose={closePermissionsDialog}
|
appTitle,
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexDirection: 'column',
|
|
||||||
md: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
|
onClose={closePermissionsDialog}
|
||||||
>
|
>
|
||||||
<img
|
|
||||||
src="/assets/camera_mic_permission.svg"
|
|
||||||
alt=""
|
|
||||||
className={css({
|
|
||||||
width: '100%',
|
|
||||||
minHeight: '290px',
|
|
||||||
maxWidth: '290px',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
maxWidth: '400px',
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
md: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<H lvl={2}>
|
<img
|
||||||
{t(`heading.${permissionLabel}`, {
|
src="/assets/camera_mic_permission.svg"
|
||||||
appTitle,
|
alt=""
|
||||||
|
className={css({
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '290px',
|
||||||
|
maxWidth: '290px',
|
||||||
})}
|
})}
|
||||||
</H>
|
/>
|
||||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
<div
|
||||||
<li>
|
className={css({
|
||||||
{isSafari() ? (
|
maxWidth: '400px',
|
||||||
t('body.openMenu.safari', {
|
})}
|
||||||
appDomain: window.origin.replace('https://', ''),
|
>
|
||||||
})
|
<H lvl={2}>
|
||||||
) : (
|
{t(`heading.${permissionLabel}`, {
|
||||||
<>
|
appTitle,
|
||||||
{descriptionBeforeIcon}
|
})}
|
||||||
<span
|
</H>
|
||||||
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||||
>
|
<li>
|
||||||
<RiEqualizer2Line />
|
{isSafari() ? (
|
||||||
</span>
|
t('body.openMenu.safari', {
|
||||||
{descriptionAfterIcon}
|
appDomain: window.origin.replace('https://', ''),
|
||||||
</>
|
})
|
||||||
)}
|
) : (
|
||||||
</li>
|
<>
|
||||||
<li>{t(`body.details.${permissionLabel}`)}</li>
|
{descriptionBeforeIcon}
|
||||||
</ol>
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiEqualizer2Line />
|
||||||
|
</span>
|
||||||
|
{descriptionAfterIcon}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li>{t(`body.details.${permissionLabel}`)}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Dialog>
|
||||||
</Dialog>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Button, H, Input, Text, TextArea } from '@/primitives'
|
import { Button, H, Text, TextArea } from '@/primitives'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { cva } from '@/styled-system/css'
|
import { cva } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { styled, VStack } from '@/styled-system/jsx'
|
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 { Button as RACButton } from 'react-aria-components'
|
||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||||
import type { CandidateInfo } from '@/stores/connectionObserver'
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
const Card = styled('div', {
|
const Card = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
@@ -72,11 +70,9 @@ const labelRecipe = cva({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const OpenFeedback = ({
|
const OpenFeedback = ({
|
||||||
posthog,
|
|
||||||
onNext,
|
onNext,
|
||||||
metadata,
|
metadata,
|
||||||
}: {
|
}: {
|
||||||
posthog: PostHog
|
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
}) => {
|
}) => {
|
||||||
@@ -90,7 +86,7 @@ const OpenFeedback = ({
|
|||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
try {
|
try {
|
||||||
posthog.capture('open-feedback', {
|
captureEvent('open-feedback', {
|
||||||
feedback,
|
feedback,
|
||||||
...metadata,
|
...metadata,
|
||||||
})
|
})
|
||||||
@@ -141,12 +137,10 @@ const OpenFeedback = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RateQuality = ({
|
const RateQuality = ({
|
||||||
posthog,
|
|
||||||
onNext,
|
onNext,
|
||||||
metadata,
|
metadata,
|
||||||
maxRating = 5,
|
maxRating = 5,
|
||||||
}: {
|
}: {
|
||||||
posthog: PostHog
|
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
maxRating?: number
|
maxRating?: number
|
||||||
@@ -160,7 +154,7 @@ const RateQuality = ({
|
|||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
try {
|
try {
|
||||||
posthog.capture('quality-rating', {
|
captureEvent('quality-rating', {
|
||||||
rating: selectedRating,
|
rating: selectedRating,
|
||||||
...metadata,
|
...metadata,
|
||||||
})
|
})
|
||||||
@@ -243,73 +237,8 @@ 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 = {
|
type RatingMetadata = {
|
||||||
room_id?: string
|
room_id?: string
|
||||||
pc_publisher?: CandidateInfo
|
|
||||||
pc_subscriber?: CandidateInfo
|
|
||||||
pc_publisher_changes_count?: number
|
|
||||||
pc_subscriber_changes_count?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Rating = ({
|
export const Rating = ({
|
||||||
@@ -318,12 +247,6 @@ export const Rating = ({
|
|||||||
metadata: RatingMetadata
|
metadata: RatingMetadata
|
||||||
}) => {
|
}) => {
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
const posthog = usePostHog()
|
|
||||||
|
|
||||||
const isUserAnonymous = useMemo(() => {
|
|
||||||
return posthog.get_property('$user_state') == 'anonymous'
|
|
||||||
}, [posthog])
|
|
||||||
|
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState(0)
|
||||||
|
|
||||||
const sessionId = useMemo(() => crypto.randomUUID(), [])
|
const sessionId = useMemo(() => crypto.randomUUID(), [])
|
||||||
@@ -339,37 +262,14 @@ export const Rating = ({
|
|||||||
if (!isAnalyticsEnabled) return
|
if (!isAnalyticsEnabled) return
|
||||||
|
|
||||||
if (step == 0) {
|
if (step == 0) {
|
||||||
return (
|
return <RateQuality onNext={() => setStep(step + 1)} metadata={metadata} />
|
||||||
<RateQuality
|
|
||||||
posthog={posthog}
|
|
||||||
onNext={() => setStep(step + 1)}
|
|
||||||
metadata={metadata}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step == 1) {
|
if (step == 1) {
|
||||||
return (
|
return <OpenFeedback onNext={() => setStep(step + 1)} metadata={metadata} />
|
||||||
<OpenFeedback
|
|
||||||
posthog={posthog}
|
|
||||||
onNext={() => setStep(step + 1)}
|
|
||||||
metadata={metadata}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step == 2) {
|
if (step == 2) {
|
||||||
return isUserAnonymous ? (
|
|
||||||
<AuthenticationMessage
|
|
||||||
posthog={posthog}
|
|
||||||
onNext={() => setStep(step + 1)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ConfirmationMessage onNext={() => setStep(0)} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (step == 3) {
|
|
||||||
return <ConfirmationMessage onNext={() => setStep(0)} />
|
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 { useEffect } from 'react'
|
||||||
import { permissionsStore } from '@/stores/permissions'
|
import { syncPermissions } from '@/stores/permissions'
|
||||||
import { isSafari } from '@/utils/livekit'
|
|
||||||
|
|
||||||
const POLLING_TIME = 500
|
|
||||||
|
|
||||||
export const useWatchPermissions = () => {
|
export const useWatchPermissions = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cleanup: (() => void) | undefined
|
const sync = () => void syncPermissions()
|
||||||
let intervalId: ReturnType<typeof setTimeout> | undefined
|
sync()
|
||||||
let isCancelled = false
|
|
||||||
|
|
||||||
const checkPermissions = async () => {
|
navigator.mediaDevices?.addEventListener?.('devicechange', sync)
|
||||||
try {
|
window.addEventListener('focus', sync)
|
||||||
if (!navigator.permissions) {
|
|
||||||
if (!isCancelled) {
|
|
||||||
permissionsStore.cameraPermission = 'unavailable'
|
|
||||||
permissionsStore.microphonePermission = 'unavailable'
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const [cameraPermission, microphonePermission] = await Promise.all([
|
let statuses: PermissionStatus[] = []
|
||||||
navigator.permissions.query({ name: 'camera' }),
|
let cancelled = false
|
||||||
navigator.permissions.query({ name: 'microphone' }),
|
if (navigator.permissions) {
|
||||||
])
|
Promise.all([
|
||||||
|
navigator.permissions.query({ name: 'camera' as PermissionName }),
|
||||||
if (isCancelled) return
|
navigator.permissions.query({ name: 'microphone' as PermissionName }),
|
||||||
|
])
|
||||||
/**
|
.then((results) => {
|
||||||
* Safari Permission API Limitation Workaround
|
if (cancelled) return
|
||||||
*
|
statuses = results
|
||||||
* Safari has a known issue where permission change events are not reliably fired
|
statuses.forEach((s) => s.addEventListener('change', sync))
|
||||||
* when users interact with permission prompts. This is documented in Apple's forums:
|
})
|
||||||
* https://developer.apple.com/forums/thread/757353
|
.catch(() => {
|
||||||
*
|
// Query unsupported: devicechange/focus + gUM outcomes cover it.
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
checkPermissions()
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isCancelled = true
|
cancelled = true
|
||||||
cleanup?.()
|
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 { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
import { queryClient } from '@/api/queryClient'
|
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useParams } from 'wouter'
|
import { useParams } from 'wouter'
|
||||||
@@ -14,6 +13,7 @@ import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { closeSidePanel } from '@/stores/layout'
|
import { closeSidePanel } from '@/stores/layout'
|
||||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export const Admin = () => {
|
export const Admin = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||||
@@ -206,11 +206,7 @@ export const Admin = () => {
|
|||||||
patchRoom({
|
patchRoom({
|
||||||
roomId,
|
roomId,
|
||||||
room: { access_level: value as ApiAccessLevel },
|
room: { access_level: value as ApiAccessLevel },
|
||||||
})
|
}).catch((e) => reportError('generic_failure', e))
|
||||||
.then((room) => {
|
|
||||||
queryClient.setQueryData([keys.room, roomId], room)
|
|
||||||
})
|
|
||||||
.catch((e) => console.error(e))
|
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,28 +9,16 @@ import { useSnapshot } from 'valtio'
|
|||||||
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||||
|
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
|
|
||||||
|
import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
|
||||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
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'
|
|
||||||
|
|
||||||
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
|
||||||
|
|
||||||
export const ConnectionObserver = () => {
|
export const ConnectionObserver = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const connectionStartTimeRef = useRef<number | null>(null)
|
const connectionStartTimeRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
|
|
||||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
|
||||||
const isMobile = isMobileBrowser()
|
|
||||||
|
|
||||||
const isAdvancedConnectionObserverEnabled =
|
|
||||||
!isMobile && isAnalyticsEnabled && featureEnabled
|
|
||||||
|
|
||||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||||
|
|
||||||
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
|
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
|
||||||
@@ -80,100 +68,6 @@ export const ConnectionObserver = () => {
|
|||||||
userPreferencesSnap.is_idle_disconnect_modal_enabled,
|
userPreferencesSnap.is_idle_disconnect_modal_enabled,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAdvancedConnectionObserverEnabled) return
|
|
||||||
if (!room) return
|
|
||||||
|
|
||||||
let interval: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
const pollCandidate = async (
|
|
||||||
label: 'publisher' | 'subscriber',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
pc?: any
|
|
||||||
) => {
|
|
||||||
if (!pc) return
|
|
||||||
|
|
||||||
let stats: RTCStatsReport
|
|
||||||
try {
|
|
||||||
stats = await pc.getStats()
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
stats.forEach((report: any) => {
|
|
||||||
if (
|
|
||||||
report.type === 'candidate-pair' &&
|
|
||||||
report.state === 'succeeded' &&
|
|
||||||
report.nominated
|
|
||||||
) {
|
|
||||||
const remoteCandidate = stats.get(report.remoteCandidateId)
|
|
||||||
if (!remoteCandidate) return
|
|
||||||
|
|
||||||
const next = {
|
|
||||||
type: remoteCandidate.candidateType,
|
|
||||||
address: remoteCandidate.address,
|
|
||||||
protocol: remoteCandidate.protocol,
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = connectionObserverStore[label]
|
|
||||||
|
|
||||||
const hasChanged =
|
|
||||||
current?.type !== next.type ||
|
|
||||||
current?.address !== next.address ||
|
|
||||||
current?.protocol !== next.protocol
|
|
||||||
|
|
||||||
if (hasChanged) {
|
|
||||||
connectionObserverStore[label] = next
|
|
||||||
const key = `${label}ChangesCount` as const
|
|
||||||
connectionObserverStore[key] =
|
|
||||||
(connectionObserverStore[key] || 0) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
const publisher = room.engine?.pcManager?.publisher
|
|
||||||
const subscriber = room.engine?.pcManager?.subscriber
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
pollCandidate('publisher', publisher),
|
|
||||||
pollCandidate('subscriber', subscriber),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPolling = async () => {
|
|
||||||
if (interval) return // prevent duplicates
|
|
||||||
|
|
||||||
// Initial snapshot
|
|
||||||
await poll()
|
|
||||||
|
|
||||||
interval = setInterval(poll, CANDIDATE_POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopPolling = () => {
|
|
||||||
if (!interval) return
|
|
||||||
clearInterval(interval)
|
|
||||||
interval = null
|
|
||||||
}
|
|
||||||
|
|
||||||
room.on(RoomEvent.Connected, startPolling)
|
|
||||||
room.on(RoomEvent.Reconnected, startPolling)
|
|
||||||
|
|
||||||
room.on(RoomEvent.Reconnecting, stopPolling)
|
|
||||||
room.on(RoomEvent.Disconnected, stopPolling)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stopPolling()
|
|
||||||
|
|
||||||
room.off(RoomEvent.Connected, startPolling)
|
|
||||||
room.off(RoomEvent.Reconnected, startPolling)
|
|
||||||
room.off(RoomEvent.Reconnecting, stopPolling)
|
|
||||||
room.off(RoomEvent.Disconnected, stopPolling)
|
|
||||||
}
|
|
||||||
}, [room, isAdvancedConnectionObserverEnabled])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAnalyticsEnabled) return
|
if (!isAnalyticsEnabled) return
|
||||||
|
|
||||||
@@ -182,23 +76,23 @@ export const ConnectionObserver = () => {
|
|||||||
// total session duration from first connect to final disconnect.
|
// total session duration from first connect to final disconnect.
|
||||||
if (connectionStartTimeRef.current != null) return
|
if (connectionStartTimeRef.current != null) return
|
||||||
connectionStartTimeRef.current = Date.now()
|
connectionStartTimeRef.current = Date.now()
|
||||||
posthog.capture('connection-event')
|
void captureMediaEvent('connection-event', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReconnect = () => {
|
const handleReconnect = () => {
|
||||||
posthog.capture('reconnect-event')
|
captureEvent('reconnect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReconnected = () => {
|
const handleReconnected = () => {
|
||||||
posthog.capture('reconnected-event')
|
captureEvent('reconnected-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSignalingConnect = () => {
|
const handleSignalingConnect = () => {
|
||||||
posthog.capture('signaling-connect-event')
|
captureEvent('signaling-connect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSignalingReconnect = () => {
|
const handleSignalingReconnect = () => {
|
||||||
posthog.capture('signaling-reconnect-event')
|
captureEvent('signaling-reconnect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDisconnect = (
|
const handleDisconnect = (
|
||||||
@@ -206,7 +100,7 @@ export const ConnectionObserver = () => {
|
|||||||
) => {
|
) => {
|
||||||
const connectionEndTime = Date.now()
|
const connectionEndTime = Date.now()
|
||||||
|
|
||||||
posthog.capture('disconnect-event', {
|
captureEvent('disconnect-event', {
|
||||||
// Calculate total session duration from first connection to final disconnect
|
// Calculate total session duration from first connection to final disconnect
|
||||||
// This duration is sensitive to refreshing the page.
|
// This duration is sensitive to refreshing the page.
|
||||||
sessionDuration: connectionStartTimeRef.current
|
sessionDuration: connectionStartTimeRef.current
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const Info = () => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||||
|
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
const roomUrl = getRouteUrl('room', data?.slug)
|
const roomUrl = data?.slug ? getRouteUrl('room', data.slug) : ''
|
||||||
|
|
||||||
const telephony = useTelephony()
|
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
|
||||||
|
}
|
||||||
+23
-13
@@ -9,15 +9,18 @@ import {
|
|||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
import { userChoicesStore } from '@/stores/userChoices'
|
||||||
|
import { performanceModeStore } from '@/stores/performanceMode'
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets initial video quality for new participants as they join.
|
|
||||||
* LiveKit doesn't allow handling video quality preferences at the room level.
|
|
||||||
*/
|
|
||||||
export const VideoResolutionSubscription = () => {
|
export const VideoResolutionSubscription = () => {
|
||||||
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
|
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
|
||||||
|
const { enabled: isPerformanceModeEnabled } =
|
||||||
|
useSnapshot(performanceModeStore)
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
|
||||||
|
const effectiveQuality = isPerformanceModeEnabled
|
||||||
|
? VideoQuality.LOW
|
||||||
|
: (videoSubscribeQuality ?? VideoQuality.HIGH)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return
|
if (!room) return
|
||||||
|
|
||||||
@@ -25,18 +28,12 @@ export const VideoResolutionSubscription = () => {
|
|||||||
publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
_participant: RemoteParticipant
|
_participant: RemoteParticipant
|
||||||
) => {
|
) => {
|
||||||
// By default, the maximum quality is set to high
|
if (effectiveQuality === VideoQuality.HIGH) return
|
||||||
if (
|
|
||||||
videoSubscribeQuality === undefined ||
|
|
||||||
videoSubscribeQuality === VideoQuality.HIGH
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
publication.kind === Track.Kind.Video &&
|
publication.kind === Track.Kind.Video &&
|
||||||
publication.source !== Track.Source.ScreenShare
|
publication.source !== Track.Source.ScreenShare
|
||||||
) {
|
) {
|
||||||
publication.setVideoQuality(videoSubscribeQuality)
|
publication.setVideoQuality(effectiveQuality)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +41,20 @@ export const VideoResolutionSubscription = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
}
|
}
|
||||||
}, [room, videoSubscribeQuality])
|
}, [room, effectiveQuality])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!room) return
|
||||||
|
|
||||||
|
room.remoteParticipants.forEach((participant) => {
|
||||||
|
participant.videoTrackPublications.forEach((publication) => {
|
||||||
|
if (publication.source === Track.Source.ScreenShare) return
|
||||||
|
if (publication.videoQuality !== effectiveQuality) {
|
||||||
|
publication.setVideoQuality(effectiveQuality)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [room, effectiveQuality])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,4 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import {
|
import {
|
||||||
FilesetResolver,
|
FilesetResolver,
|
||||||
ImageSegmenter,
|
ImageSegmenter,
|
||||||
@@ -18,6 +17,7 @@ import {
|
|||||||
type ProcessorType,
|
type ProcessorType,
|
||||||
MEDIAPIPE_PATH_WASM,
|
MEDIAPIPE_PATH_WASM,
|
||||||
} from '.'
|
} from '.'
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry.ts'
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256
|
const PROCESSING_WIDTH = 256
|
||||||
const PROCESSING_HEIGHT = 144
|
const PROCESSING_HEIGHT = 144
|
||||||
@@ -100,7 +100,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
await this.initSegmenter()
|
await this.initSegmenter()
|
||||||
this._initWorker()
|
this._initWorker()
|
||||||
|
|
||||||
posthog.capture('firefox-blurring-init')
|
captureEvent('firefox-blurring-init', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
_initVirtualBackgroundImage() {
|
_initVirtualBackgroundImage() {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
|
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import {
|
import {
|
||||||
FilesetResolver,
|
FilesetResolver,
|
||||||
FaceLandmarker,
|
FaceLandmarker,
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
ProcessorType,
|
ProcessorType,
|
||||||
MEDIAPIPE_PATH_WASM,
|
MEDIAPIPE_PATH_WASM,
|
||||||
} from '.'
|
} from '.'
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256 * 3
|
const PROCESSING_WIDTH = 256 * 3
|
||||||
const PROCESSING_HEIGHT = 144 * 3
|
const PROCESSING_HEIGHT = 144 * 3
|
||||||
@@ -101,7 +101,7 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
|||||||
await this.initFaceLandmarker()
|
await this.initFaceLandmarker()
|
||||||
this._initWorker()
|
this._initWorker()
|
||||||
|
|
||||||
posthog.capture('face-landmarks-init')
|
captureEvent('face-landmarks-init', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
_initWorker() {
|
_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 type { Track, TrackProcessor } from 'livekit-client'
|
||||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||||
@@ -10,7 +13,7 @@ export const SELFIE_SEGMENTER_MODEL_PATH =
|
|||||||
export const FACE_LANDMARKS_MODEL_PATH =
|
export const FACE_LANDMARKS_MODEL_PATH =
|
||||||
'/assets/mediapipe/models/face_landmarker.task'
|
'/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 {
|
export enum ProcessorType {
|
||||||
BLUR = 'blur',
|
BLUR = 'blur',
|
||||||
@@ -34,7 +37,9 @@ export class BackgroundProcessorFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static isSupported() {
|
static isSupported() {
|
||||||
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
|
return (
|
||||||
|
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static getProcessor(
|
static getProcessor(
|
||||||
@@ -45,7 +50,7 @@ export class BackgroundProcessorFactory {
|
|||||||
|
|
||||||
if (!isBlur && !isVirtual) return undefined
|
if (!isBlur && !isVirtual) return undefined
|
||||||
|
|
||||||
if (ProcessorWrapper.isSupported) {
|
if (supportsBackgroundProcessors()) {
|
||||||
return new UnifiedBackgroundTrackProcessor(config)
|
return new UnifiedBackgroundTrackProcessor(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RiCameraSwitchLine } from '@remixicon/react'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { ButtonProps } from 'react-aria-components'
|
import type { ButtonProps } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
enum FacingMode {
|
enum FacingMode {
|
||||||
USER = 'user',
|
USER = 'user',
|
||||||
@@ -103,7 +104,11 @@ export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
|
|||||||
setActiveMediaDevice(device.deviceId)
|
setActiveMediaDevice(device.deviceId)
|
||||||
setFacingMode(target)
|
setFacingMode(target)
|
||||||
} else {
|
} 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 (
|
return (
|
||||||
|
|||||||
+13
-2
@@ -1,8 +1,12 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
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 { Button, Popover } from '@/primitives'
|
||||||
import { RiArrowUpSLine } from '@remixicon/react'
|
import { RiArrowUpSLine } from '@remixicon/react'
|
||||||
import { Track } from 'livekit-client'
|
import { LocalAudioTrack, Track } from 'livekit-client'
|
||||||
|
|
||||||
import { ToggleDevice } from './ToggleDevice'
|
import { ToggleDevice } from './ToggleDevice'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
@@ -51,6 +55,12 @@ export const AudioDevicesControl = ({
|
|||||||
...props,
|
...props,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { microphoneTrack } = useLocalParticipant()
|
||||||
|
const localAudioTrack =
|
||||||
|
microphoneTrack?.track instanceof LocalAudioTrack
|
||||||
|
? microphoneTrack.track
|
||||||
|
: undefined
|
||||||
|
|
||||||
const kind = 'audioinput'
|
const kind = 'audioinput'
|
||||||
const cannotUseDevice = useCannotUseDevice(kind)
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
||||||
@@ -111,6 +121,7 @@ export const AudioDevicesControl = ({
|
|||||||
context="room"
|
context="room"
|
||||||
kind={kind}
|
kind={kind}
|
||||||
id={audioDeviceId}
|
id={audioDeviceId}
|
||||||
|
track={localAudioTrack}
|
||||||
onSubmit={saveAudioInputDeviceId}
|
onSubmit={saveAudioInputDeviceId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user