mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-15 13:13:56 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2bfbfd0cf |
@@ -8,19 +8,6 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### 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
|
### Added
|
||||||
|
|
||||||
- ✨(summary) report exception type in failure analytics
|
- ✨(summary) report exception type in failure analytics
|
||||||
@@ -30,9 +17,6 @@ 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
|
||||||
|
|
||||||
@@ -58,12 +42,6 @@ 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,11 +65,6 @@ 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,6 +104,3 @@ 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.25.1"
|
version = "1.24.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.25.1"
|
version = "1.24.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ 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,7 +17,6 @@ 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,15 +85,3 @@ 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,10 +2,8 @@
|
|||||||
# 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
|
||||||
@@ -29,9 +27,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -41,7 +36,6 @@ 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
|
||||||
@@ -99,9 +93,7 @@ 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
|
||||||
@@ -1571,68 +1563,3 @@ 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, gettext, override
|
from django.utils.translation import get_language, 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 = gettext("Your recording is ready") # Force translation
|
subject = str(_("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 gettext("Transcription")
|
return _("Transcription")
|
||||||
|
|
||||||
dt = recording_datetime
|
dt = recording_datetime
|
||||||
if owner_timezone:
|
if owner_timezone:
|
||||||
|
|||||||
@@ -137,13 +137,6 @@ 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
|
||||||
@@ -235,11 +228,6 @@ 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,7 +8,6 @@ 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,
|
||||||
@@ -89,30 +88,3 @@ 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()
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
"""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",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
"""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,7 +5,6 @@ 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
|
||||||
|
|
||||||
@@ -419,63 +418,3 @@ 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,7 +720,6 @@ 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
|
||||||
|
|
||||||
@@ -824,33 +823,3 @@ 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()
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
"""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,11 +30,6 @@ 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,7 +12,6 @@ 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
|
||||||
@@ -68,7 +67,6 @@ 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.
|
||||||
|
|
||||||
@@ -84,7 +82,6 @@ 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.
|
||||||
@@ -138,8 +135,6 @@ def generate_token( # noqa: PLR0917
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if ttl is not None:
|
|
||||||
token = token.with_ttl(ttl)
|
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|||||||
@@ -354,11 +354,6 @@ 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 = (
|
||||||
@@ -665,30 +660,6 @@ 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
|
||||||
)
|
)
|
||||||
@@ -1299,8 +1270,6 @@ 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.25.1"
|
version = "1.24.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.25.1"
|
version = "1.24.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ 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 ----
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ server {
|
|||||||
|
|
||||||
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)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
|
|||||||
Generated
+10
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.25.1",
|
"version": "1.24.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.25.1",
|
"version": "1.24.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,7 +15,7 @@
|
|||||||
"@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.14",
|
"@mediapipe/tasks-vision": "0.10.35",
|
||||||
"@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",
|
||||||
@@ -1049,12 +1049,18 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mediapipe/tasks-vision": {
|
"node_modules/@livekit/track-processors/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",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.25.1",
|
"version": "1.24.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"@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.14",
|
"@mediapipe/tasks-vision": "0.10.35",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"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"}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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,9 +45,6 @@ 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, { useLayoutEffect, useMemo } from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const avatar = cva({
|
const avatar = cva({
|
||||||
base: {
|
base: {
|
||||||
@@ -28,34 +28,13 @@ 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 = getFirstGrapheme(words[0])
|
const first = words[0].charAt(0)
|
||||||
const second = words.length > 1 ? getFirstGrapheme(words[1]) : ''
|
const second = words.length > 1 ? words[1].charAt(0) : ''
|
||||||
return (first + second).toLocaleUpperCase()
|
return (first + second).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
@@ -65,37 +44,7 @@ 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 = useMemo(() => getInitials(name), [name])
|
const initials = getInitials(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 }}
|
||||||
@@ -108,17 +57,16 @@ 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"
|
||||||
transform={`translate(0 ${offsetY})`}
|
dy="-0.08em"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize="52"
|
fontSize="52"
|
||||||
fontWeight="500"
|
fontWeight="500"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
{initials}
|
{initials.toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
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',
|
|
||||||
})
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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' }),
|
|
||||||
}
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,7 @@ 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
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ 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
|
||||||
@@ -35,12 +27,7 @@ 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
|
||||||
}) => {
|
}) => {
|
||||||
const query = username ? `?username=${encodeURIComponent(username)}` : ''
|
return fetchApi<ApiRoom>(
|
||||||
|
`/rooms/${roomId}?username=${encodeURIComponent(username)}`
|
||||||
return fetchApi<ApiRoom>(`/rooms/${roomId}/${query}`)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ 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
|
||||||
@@ -17,25 +15,11 @@ 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,
|
||||||
onMutate: async ({ roomId, room: partialRoom }) => {
|
onSuccess: options?.onSuccess,
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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'
|
||||||
@@ -205,7 +206,11 @@ export const Admin = () => {
|
|||||||
patchRoom({
|
patchRoom({
|
||||||
roomId,
|
roomId,
|
||||||
room: { access_level: value as ApiAccessLevel },
|
room: { access_level: value as ApiAccessLevel },
|
||||||
}).catch((e) => console.error(e))
|
})
|
||||||
|
.then((room) => {
|
||||||
|
queryClient.setQueryData([keys.room, roomId], room)
|
||||||
|
})
|
||||||
|
.catch((e) => console.error(e))
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,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/${__MEDIAPIPE_VERSION__}`
|
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
|
||||||
|
|
||||||
export enum ProcessorType {
|
export enum ProcessorType {
|
||||||
BLUR = 'blur',
|
BLUR = 'blur',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { queryClient } from '@/api/queryClient'
|
||||||
|
import { keys } from '@/api/queryKeys'
|
||||||
|
|
||||||
export const usePermissionsManager = () => {
|
export const usePermissionsManager = () => {
|
||||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||||
@@ -21,11 +23,13 @@ export const usePermissionsManager = () => {
|
|||||||
everyone_can_mute: enabled,
|
everyone_can_mute: enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
await patchRoom({
|
const room = await patchRoom({
|
||||||
roomId,
|
roomId,
|
||||||
room: { configuration: newConfiguration },
|
room: { configuration: newConfiguration },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
queryClient.setQueryData([keys.room, roomId], room)
|
||||||
|
|
||||||
return { configuration: newConfiguration }
|
return { configuration: newConfiguration }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update muting permission:', error)
|
console.error('Failed to update muting permission:', error)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { RoomEvent, Track } from 'livekit-client'
|
import { RoomEvent, Track } from 'livekit-client'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
|
import { queryClient } from '@/api/queryClient'
|
||||||
|
import { keys } from '@/api/queryKeys'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||||
import { useRemoteParticipants } from '@livekit/components-react'
|
import { useRemoteParticipants } from '@livekit/components-react'
|
||||||
@@ -77,11 +79,13 @@ export const usePublishSourcesManager = () => {
|
|||||||
can_publish_sources: newSources,
|
can_publish_sources: newSources,
|
||||||
}
|
}
|
||||||
|
|
||||||
await patchRoom({
|
const room = await patchRoom({
|
||||||
roomId,
|
roomId,
|
||||||
room: { configuration: newConfiguration },
|
room: { configuration: newConfiguration },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
queryClient.setQueryData([keys.room, roomId], room)
|
||||||
|
|
||||||
await updateParticipantsPermissions(
|
await updateParticipantsPermissions(
|
||||||
unprivilegedRemoteParticipants,
|
unprivilegedRemoteParticipants,
|
||||||
newSources
|
newSources
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Link } from 'react-aria-components'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { HStack, VStack } from '@/styled-system/jsx'
|
import { HStack, VStack } from '@/styled-system/jsx'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { RiCloseLine, RiFileCopyLine, RiSettings3Line } from '@remixicon/react'
|
import { RiCloseLine, RiFileCopyLine } from '@remixicon/react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
import { buttonRecipe } from '@/primitives/buttonRecipe'
|
import { buttonRecipe } from '@/primitives/buttonRecipe'
|
||||||
@@ -37,49 +37,15 @@ const CreateMeetingButton = () => {
|
|||||||
initialRoom
|
initialRoom
|
||||||
)
|
)
|
||||||
|
|
||||||
const [isRoomCreatedInSession, setIsRoomCreatedInSession] = useState(false)
|
|
||||||
|
|
||||||
const showSettingsButton =
|
|
||||||
isRoomCreatedInSession || searchParams.get('settings') === 'true'
|
|
||||||
|
|
||||||
const { data } = useRoomCreationCallback({ callbackId })
|
const { data } = useRoomCreationCallback({ callbackId })
|
||||||
|
|
||||||
const roomUrl = useMemo(() => {
|
const roomUrl = useMemo(() => {
|
||||||
if (room?.slug) return getRouteUrl('room', room.slug)
|
if (room?.slug) return getRouteUrl('room', room.slug)
|
||||||
}, [room])
|
}, [room])
|
||||||
|
|
||||||
const backgroundColor = useMemo(() => {
|
|
||||||
const param = searchParams.get('backgroundColor')
|
|
||||||
if (!param) return 'transparent'
|
|
||||||
|
|
||||||
const value = param.trim()
|
|
||||||
|
|
||||||
// Allow raw hex passed without '#' (e.g. ?backgroundColor=ff0000)
|
|
||||||
if (
|
|
||||||
/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{4}$|^[0-9a-fA-F]{6}$|^[0-9a-fA-F]{8}$/.test(
|
|
||||||
value
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return `#${value}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already-valid hex (e.g. URL-encoded %23ff0000 → '#ff0000')
|
|
||||||
if (/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(value)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: only allow simple named colors, block anything injectable
|
|
||||||
if (/^[a-zA-Z]+$/.test(value)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'transparent'
|
|
||||||
}, [searchParams])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data?.room?.slug) return
|
if (!data?.room?.slug) return
|
||||||
setRoom(data.room)
|
setRoom(data.room)
|
||||||
setIsRoomCreatedInSession(true)
|
|
||||||
setCallbackId(undefined)
|
setCallbackId(undefined)
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
popupManager.sendRoomData({
|
popupManager.sendRoomData({
|
||||||
@@ -95,7 +61,6 @@ const CreateMeetingButton = () => {
|
|||||||
(id) => setCallbackId(id),
|
(id) => setCallbackId(id),
|
||||||
(data) => {
|
(data) => {
|
||||||
setRoom(data)
|
setRoom(data)
|
||||||
setIsRoomCreatedInSession(true)
|
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -105,7 +70,6 @@ const CreateMeetingButton = () => {
|
|||||||
|
|
||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
setRoom(undefined)
|
setRoom(undefined)
|
||||||
setIsRoomCreatedInSession(false)
|
|
||||||
setCallbackId(undefined)
|
setCallbackId(undefined)
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
popupManager.clearState()
|
popupManager.clearState()
|
||||||
@@ -113,12 +77,6 @@ const CreateMeetingButton = () => {
|
|||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
height: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -135,7 +93,6 @@ const CreateMeetingButton = () => {
|
|||||||
aria-label={t('resetLabel')}
|
aria-label={t('resetLabel')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +104,6 @@ const CreateMeetingButton = () => {
|
|||||||
justifyContent: 'start',
|
justifyContent: 'start',
|
||||||
alignItems: 'start',
|
alignItems: 'start',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
backgroundColor: backgroundColor,
|
|
||||||
height: '100%',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{roomUrl && room?.slug ? (
|
{roomUrl && room?.slug ? (
|
||||||
@@ -166,25 +121,14 @@ const CreateMeetingButton = () => {
|
|||||||
{t('joinButton')}
|
{t('joinButton')}
|
||||||
</Link>
|
</Link>
|
||||||
<HStack gap={0}>
|
<HStack gap={0}>
|
||||||
{showSettingsButton && (
|
|
||||||
<Button
|
|
||||||
variant="quaternaryText"
|
|
||||||
square
|
|
||||||
icon={<RiSettings3Line />}
|
|
||||||
aria-label={t('settingsTooltip')}
|
|
||||||
onPress={() => {
|
|
||||||
popupManager.createSettingsPopupWindow(room.slug, () => {})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="quaternaryText"
|
variant="quaternaryText"
|
||||||
square
|
square
|
||||||
icon={<RiFileCopyLine />}
|
icon={<RiFileCopyLine />}
|
||||||
|
tooltip={t('copyLinkTooltip')}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigator.clipboard.writeText(roomUrl)
|
navigator.clipboard.writeText(roomUrl)
|
||||||
}}
|
}}
|
||||||
aria-label={t('copyLinkTooltip')}
|
|
||||||
/>
|
/>
|
||||||
{searchParams.get('readOnly') === 'false' && (
|
{searchParams.get('readOnly') === 'false' && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,374 +0,0 @@
|
|||||||
import { useEffect, useMemo, type ReactNode } from 'react'
|
|
||||||
import { useSearchParams } from 'wouter'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Button, Field, H, Text } from '@/primitives'
|
|
||||||
import { Spinner } from '@/primitives/Spinner'
|
|
||||||
import { keys } from '@/api/queryKeys'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
|
||||||
import { authUrl } from '@/features/auth/utils/authUrl'
|
|
||||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
|
||||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
|
||||||
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
|
||||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
|
||||||
|
|
||||||
type Source = Track.Source
|
|
||||||
|
|
||||||
const SectionHeader = ({ children }: { children: ReactNode }) => (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
backgroundColor: 'greyscale.50',
|
|
||||||
borderTopWidth: '1px',
|
|
||||||
borderTopStyle: 'solid',
|
|
||||||
borderTopColor: 'greyscale.250',
|
|
||||||
borderBottomWidth: '1px',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
borderBottomColor: 'greyscale.250',
|
|
||||||
padding: '0.75rem 1.5rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<H
|
|
||||||
lvl={2}
|
|
||||||
margin={false}
|
|
||||||
className={css({
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: '1.125rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</H>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SectionBody = ({ children }: { children: ReactNode }) => (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
padding: '1rem 1.5rem 1.5rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SettingsPopup = () => {
|
|
||||||
const { t } = useTranslation('sdk', { keyPrefix: 'roomSettings' })
|
|
||||||
const { t: tRooms } = useTranslation('rooms', { keyPrefix: 'admin' })
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
const roomSlug = searchParams.get('slug')?.trim()
|
|
||||||
|
|
||||||
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoggedIn === false) {
|
|
||||||
// returnTo defaults to the current URL, so the user comes back to this
|
|
||||||
// popup (with the slug preserved) once authentication completes.
|
|
||||||
window.location.href = authUrl({})
|
|
||||||
}
|
|
||||||
}, [isLoggedIn])
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: room,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [keys.room, roomSlug],
|
|
||||||
queryFn: () => fetchRoom({ roomId: roomSlug as string }),
|
|
||||||
enabled: !!isLoggedIn && !!roomSlug,
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
|
||||||
const { data: configData } = useConfig()
|
|
||||||
|
|
||||||
const configuration = room?.configuration
|
|
||||||
|
|
||||||
const currentSources = useMemo(() => {
|
|
||||||
const defaultSources = configData?.livekit?.default_sources ?? []
|
|
||||||
|
|
||||||
if (
|
|
||||||
configuration?.can_publish_sources == undefined ||
|
|
||||||
!Array.isArray(configuration?.can_publish_sources)
|
|
||||||
) {
|
|
||||||
return defaultSources
|
|
||||||
}
|
|
||||||
return configuration.can_publish_sources
|
|
||||||
}, [configData, configuration?.can_publish_sources])
|
|
||||||
|
|
||||||
const patchConfiguration = (
|
|
||||||
newConfiguration: NonNullable<typeof configuration>
|
|
||||||
) => {
|
|
||||||
if (!roomSlug) return
|
|
||||||
patchRoom({
|
|
||||||
roomId: roomSlug,
|
|
||||||
room: { configuration: newConfiguration },
|
|
||||||
}).catch((e) => console.error(e))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSource = (sources: Source[], enabled: boolean) => {
|
|
||||||
patchConfiguration({
|
|
||||||
...configuration,
|
|
||||||
can_publish_sources: updatePublishSources(
|
|
||||||
currentSources,
|
|
||||||
sources,
|
|
||||||
enabled
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleMicrophone = (enabled: boolean) =>
|
|
||||||
updateSource([Track.Source.Microphone], enabled)
|
|
||||||
const toggleCamera = (enabled: boolean) =>
|
|
||||||
updateSource([Track.Source.Camera], enabled)
|
|
||||||
const toggleScreenShare = (enabled: boolean) =>
|
|
||||||
updateSource(
|
|
||||||
[Track.Source.ScreenShare, Track.Source.ScreenShareAudio],
|
|
||||||
enabled
|
|
||||||
)
|
|
||||||
const toggleMuting = (enabled: boolean) =>
|
|
||||||
patchConfiguration({
|
|
||||||
...configuration,
|
|
||||||
everyone_can_mute: enabled,
|
|
||||||
})
|
|
||||||
|
|
||||||
const isMicrophoneEnabled = isSubsetOf(
|
|
||||||
[Track.Source.Microphone],
|
|
||||||
currentSources
|
|
||||||
)
|
|
||||||
const isCameraEnabled = isSubsetOf([Track.Source.Camera], currentSources)
|
|
||||||
const isScreenShareEnabled = isSubsetOf(
|
|
||||||
[Track.Source.ScreenShare, Track.Source.ScreenShareAudio],
|
|
||||||
currentSources
|
|
||||||
)
|
|
||||||
const isMutingEnabled = configuration?.everyone_can_mute ?? true
|
|
||||||
|
|
||||||
const renderCentered = (children: ReactNode) => (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
padding: '1.5rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!roomSlug || isError) {
|
|
||||||
return renderCentered(
|
|
||||||
<Text variant="note" margin={false}>
|
|
||||||
{t('error')}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoggedIn === undefined || isLoggedIn === false || isLoading || !room) {
|
|
||||||
return renderCentered(<Spinner />)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAdministrable = room.accesses !== undefined
|
|
||||||
|
|
||||||
if (!isAdministrable) {
|
|
||||||
return renderCentered(
|
|
||||||
<Text variant="note" margin={false}>
|
|
||||||
{t('notAllowed')}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
minHeight: 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<header
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: '0.5rem',
|
|
||||||
padding: '1.5rem',
|
|
||||||
borderBottomWidth: '1px',
|
|
||||||
borderBottomStyle: 'solid',
|
|
||||||
borderBottomColor: 'greyscale.250',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="/assets/logo.svg"
|
|
||||||
alt=""
|
|
||||||
className={css({
|
|
||||||
maxHeight: '40px',
|
|
||||||
flexShrink: 0,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<div className={css({ display: 'flex', flexDirection: 'column' })}>
|
|
||||||
<H
|
|
||||||
lvl={1}
|
|
||||||
margin={false}
|
|
||||||
className={css({
|
|
||||||
fontWeight: 500,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('title')}
|
|
||||||
</H>
|
|
||||||
<Text variant="smNote" margin={false}>
|
|
||||||
{roomSlug}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
flexGrow: 1,
|
|
||||||
overflowY: 'auto',
|
|
||||||
minHeight: 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<SectionHeader>{tRooms('moderation.title')}</SectionHeader>
|
|
||||||
<SectionBody>
|
|
||||||
<Text
|
|
||||||
variant="note"
|
|
||||||
wrap="balance"
|
|
||||||
className={css({
|
|
||||||
textStyle: 'sm',
|
|
||||||
})}
|
|
||||||
margin={'md'}
|
|
||||||
>
|
|
||||||
{tRooms('moderation.description')}
|
|
||||||
</Text>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.75rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tRooms('moderation.microphone.label')}
|
|
||||||
description={tRooms('moderation.microphone.description')}
|
|
||||||
isSelected={isMicrophoneEnabled}
|
|
||||||
onChange={toggleMicrophone}
|
|
||||||
wrapperProps={{
|
|
||||||
noMargin: true,
|
|
||||||
fullWidth: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tRooms('moderation.camera.label')}
|
|
||||||
description={tRooms('moderation.camera.description')}
|
|
||||||
isSelected={isCameraEnabled}
|
|
||||||
onChange={toggleCamera}
|
|
||||||
wrapperProps={{
|
|
||||||
noMargin: true,
|
|
||||||
fullWidth: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tRooms('moderation.screenshare.label')}
|
|
||||||
description={tRooms('moderation.screenshare.description')}
|
|
||||||
isSelected={isScreenShareEnabled}
|
|
||||||
onChange={toggleScreenShare}
|
|
||||||
wrapperProps={{
|
|
||||||
noMargin: true,
|
|
||||||
fullWidth: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tRooms('moderation.mute.label')}
|
|
||||||
description={tRooms('moderation.mute.description')}
|
|
||||||
isSelected={isMutingEnabled}
|
|
||||||
onChange={toggleMuting}
|
|
||||||
wrapperProps={{
|
|
||||||
noMargin: true,
|
|
||||||
fullWidth: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SectionBody>
|
|
||||||
<SectionHeader>{tRooms('access.title')}</SectionHeader>
|
|
||||||
<SectionBody>
|
|
||||||
<Text
|
|
||||||
variant="note"
|
|
||||||
wrap="balance"
|
|
||||||
className={css({
|
|
||||||
textStyle: 'sm',
|
|
||||||
})}
|
|
||||||
margin={'md'}
|
|
||||||
>
|
|
||||||
{tRooms('access.description')}
|
|
||||||
</Text>
|
|
||||||
<Field
|
|
||||||
type="radioGroup"
|
|
||||||
label={tRooms('access.type')}
|
|
||||||
aria-label={tRooms('access.type')}
|
|
||||||
labelProps={{
|
|
||||||
className: css({
|
|
||||||
fontSize: '1rem',
|
|
||||||
paddingBottom: '1rem',
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
value={room.access_level}
|
|
||||||
onChange={(value) =>
|
|
||||||
patchRoom({
|
|
||||||
roomId: roomSlug,
|
|
||||||
room: { access_level: value as ApiAccessLevel },
|
|
||||||
}).catch((e) => console.error(e))
|
|
||||||
}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.PUBLIC,
|
|
||||||
label: tRooms('access.levels.public.label'),
|
|
||||||
description: tRooms('access.levels.public.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.TRUSTED,
|
|
||||||
label: tRooms('access.levels.trusted.label'),
|
|
||||||
description: tRooms('access.levels.trusted.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.RESTRICTED,
|
|
||||||
label: tRooms('access.levels.restricted.label'),
|
|
||||||
description: tRooms('access.levels.restricted.description'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</SectionBody>
|
|
||||||
</div>
|
|
||||||
<footer
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: '1rem',
|
|
||||||
padding: '1rem 1.5rem',
|
|
||||||
borderTopWidth: '1px',
|
|
||||||
borderTopStyle: 'solid',
|
|
||||||
borderTopColor: 'greyscale.250',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Button size="sm" onPress={() => window.close()}>
|
|
||||||
{t('closeButton')}
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SettingsPopup
|
|
||||||
@@ -24,20 +24,6 @@ export class PopupManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public createSettingsPopupWindow(roomSlug: string, onFailure: () => void) {
|
|
||||||
const popupWindow = window.open(
|
|
||||||
`${window.location.origin}/sdk/settings-popup?slug=${encodeURIComponent(roomSlug)}`,
|
|
||||||
'SettingsPopupWindow',
|
|
||||||
`status=no,location=no,toolbar=no,menubar=no,width=600,height=800,left=100,top=100, resizable=yes,scrollbars=yes`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (popupWindow) {
|
|
||||||
popupWindow.focus()
|
|
||||||
} else {
|
|
||||||
onFailure()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private messageParent(type: ClientMessageType, data: any) {
|
private messageParent(type: ClientMessageType, data: any) {
|
||||||
window?.parent.postMessage(
|
window?.parent.postMessage(
|
||||||
|
|||||||
@@ -266,18 +266,6 @@ export const Footer = () => {
|
|||||||
{t('links.accessibility')}
|
{t('links.accessibility')}
|
||||||
</Link>
|
</Link>
|
||||||
</StyledLi>
|
</StyledLi>
|
||||||
{data?.diagnostics?.connection_test_enabled && (
|
|
||||||
<StyledLi divider>
|
|
||||||
<Link
|
|
||||||
underline={false}
|
|
||||||
footer="minor"
|
|
||||||
to="/test-connection"
|
|
||||||
aria-label={t('links.connectionTest')}
|
|
||||||
>
|
|
||||||
{t('links.connectionTest')}
|
|
||||||
</Link>
|
|
||||||
</StyledLi>
|
|
||||||
)}
|
|
||||||
<StyledLi>
|
<StyledLi>
|
||||||
<A
|
<A
|
||||||
externalIcon
|
externalIcon
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Ihre Konfiguration testen",
|
|
||||||
"runTest": "Test starten",
|
|
||||||
"runAgain": "Erneut testen",
|
|
||||||
"cancel": "Abbrechen",
|
|
||||||
"detailsFor": "Details zu Schritt {{step}}",
|
|
||||||
"downloadReport": "Bericht herunterladen",
|
|
||||||
"homeLink": "Ihre Konfiguration testen",
|
|
||||||
"progress": "{{done}}/{{total}}",
|
|
||||||
"progressLabel": "Fortschritt des Verbindungstests",
|
|
||||||
"groups": {
|
|
||||||
"local": "Browser und Geräte",
|
|
||||||
"network": "Verbindung zum Server"
|
|
||||||
},
|
|
||||||
"steps": {
|
|
||||||
"browser": "Browser",
|
|
||||||
"microphone": "Mikrofon",
|
|
||||||
"camera": "Kamera",
|
|
||||||
"devices": "Mediengeräte",
|
|
||||||
"websocket": "WebSocket",
|
|
||||||
"webrtc": "WebRTC",
|
|
||||||
"turn": "TURN",
|
|
||||||
"reconnect": "Erneut verbinden",
|
|
||||||
"selectedCandidate": "Ausgewählte Route",
|
|
||||||
"publishAudio": "Audio veröffentlichen",
|
|
||||||
"publishVideo": "Video veröffentlichen"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"pending": "Ausstehend",
|
|
||||||
"running": "Läuft…",
|
|
||||||
"success": "Erfolgreich",
|
|
||||||
"failed": "Fehlgeschlagen",
|
|
||||||
"skipped": "Übersprungen"
|
|
||||||
},
|
|
||||||
"counts": {
|
|
||||||
"passed": "erfolgreich",
|
|
||||||
"failed": "fehlgeschlagen",
|
|
||||||
"skipped": "übersprungen"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"idle": "Bereit zum Testen",
|
|
||||||
"idleHint": "Der Test dauert ungefähr eine Minute. Ihre Kamera und Ihr Mikrofon werden nur während des Tests verwendet.",
|
|
||||||
"running": "Test wird durchgeführt…",
|
|
||||||
"runningHint": "Lassen Sie diese Seite geöffnet, bis alle Prüfungen abgeschlossen sind.",
|
|
||||||
"passed": "Alles funktioniert",
|
|
||||||
"passedHint": "Ihr Browser, Ihre Geräte und Ihr Netzwerk sind für eine Besprechung bereit.",
|
|
||||||
"partial": "Teilweiser Test",
|
|
||||||
"partialHint": "Einige Prüfungen wurden übersprungen. Erlauben Sie den Zugriff auf Ihre Kamera und Ihr Mikrofon, um diese zu testen.",
|
|
||||||
"failed_one": "{{count}} Prüfung fehlgeschlagen",
|
|
||||||
"failed_other": "{{count}} Prüfungen fehlgeschlagen",
|
|
||||||
"failedHint": "Öffnen Sie die fehlgeschlagenen Prüfungen für weitere Details und senden Sie den Bericht an Ihre IT-Abteilung."
|
|
||||||
},
|
|
||||||
"help": {
|
|
||||||
"firewall": "Wenn Netzwerktests fehlschlagen, überprüfen Sie Ihre Browserberechtigungen und die Netzwerkfilterregeln (WebRTC, WebSocket, TURN) mit Ihrer IT-Abteilung."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
"legalsTerms": "Rechtliche Hinweise",
|
"legalsTerms": "Rechtliche Hinweise",
|
||||||
"data": "Datenschutz und Cookies",
|
"data": "Datenschutz und Cookies",
|
||||||
"accessibility": "Barrierefreiheit: nicht konform",
|
"accessibility": "Barrierefreiheit: nicht konform",
|
||||||
"connectionTest": "Testen Sie Ihre Konfiguration",
|
|
||||||
"ariaLabel": "neues Fenster",
|
"ariaLabel": "neues Fenster",
|
||||||
"codeAnnotation": "Unser Code ist offen und verfügbar in diesem",
|
"codeAnnotation": "Unser Code ist offen und verfügbar in diesem",
|
||||||
"code": "Open-Source-Repository",
|
"code": "Open-Source-Repository",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"moreLinkLabel": "Mehr über {{appTitle}} erfahren – neuer Tab",
|
"moreLinkLabel": "Mehr über {{appTitle}} erfahren – neuer Tab",
|
||||||
"moreLink": "Mehr erfahren",
|
"moreLink": "Mehr erfahren",
|
||||||
"moreAbout": "über {{appTitle}}",
|
"moreAbout": "über {{appTitle}}",
|
||||||
"connectionTestLink": "Testen Sie Ihre Konfiguration",
|
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Meeting für später planen",
|
"laterOption": "Meeting für später planen",
|
||||||
"instantOption": "Meeting sofort starten"
|
"instantOption": "Meeting sofort starten"
|
||||||
|
|||||||
@@ -5,14 +5,6 @@
|
|||||||
"copyLinkTooltip": "Link kopieren",
|
"copyLinkTooltip": "Link kopieren",
|
||||||
"resetLabel": "Zurücksetzen",
|
"resetLabel": "Zurücksetzen",
|
||||||
"participantLimit": "Bis zu 150 Teilnehmende.",
|
"participantLimit": "Bis zu 150 Teilnehmende.",
|
||||||
"popupBlocked": "Popup wurde blockiert. Bitte erlaube Popups für diese Website.",
|
"popupBlocked": "Popup wurde blockiert. Bitte erlaube Popups für diese Website."
|
||||||
"settingsTooltip": "Besprechungseinstellungen"
|
|
||||||
},
|
|
||||||
"roomSettings": {
|
|
||||||
"title": "Besprechungseinstellungen",
|
|
||||||
"description": "Konfiguriere die Besprechung {{roomSlug}}.",
|
|
||||||
"notAllowed": "Du hast keine Berechtigung, die Einstellungen dieser Besprechung zu ändern.",
|
|
||||||
"error": "Die Besprechungseinstellungen konnten nicht geladen werden.",
|
|
||||||
"closeButton": "Schließen"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Test your configuration",
|
|
||||||
"runTest": "Run test",
|
|
||||||
"runAgain": "Run again",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"detailsFor": "Details for {{step}}",
|
|
||||||
"downloadReport": "Download report",
|
|
||||||
"homeLink": "Test your configuration",
|
|
||||||
"progress": "{{done}}/{{total}}",
|
|
||||||
"progressLabel": "Connection test progress",
|
|
||||||
"groups": {
|
|
||||||
"local": "Browser and devices",
|
|
||||||
"network": "Server connectivity"
|
|
||||||
},
|
|
||||||
"steps": {
|
|
||||||
"browser": "Browser",
|
|
||||||
"microphone": "Microphone",
|
|
||||||
"camera": "Camera",
|
|
||||||
"devices": "Media devices",
|
|
||||||
"websocket": "WebSocket",
|
|
||||||
"webrtc": "WebRTC",
|
|
||||||
"turn": "TURN",
|
|
||||||
"reconnect": "Reconnect",
|
|
||||||
"selectedCandidate": "Selected route",
|
|
||||||
"publishAudio": "Audio publishing",
|
|
||||||
"publishVideo": "Video publishing"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"pending": "Pending",
|
|
||||||
"running": "Running…",
|
|
||||||
"success": "Passed",
|
|
||||||
"failed": "Failed",
|
|
||||||
"skipped": "Skipped"
|
|
||||||
},
|
|
||||||
"counts": {
|
|
||||||
"passed": "passed",
|
|
||||||
"failed": "failed",
|
|
||||||
"skipped": "skipped"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"idle": "Ready to test",
|
|
||||||
"idleHint": "The test takes about a minute. Your camera and microphone are only used while it runs.",
|
|
||||||
"running": "Testing…",
|
|
||||||
"runningHint": "Keep this page open until every check has finished.",
|
|
||||||
"passed": "Everything works",
|
|
||||||
"passedHint": "Your browser, your devices and your network are ready for a meeting.",
|
|
||||||
"partial": "Partially tested",
|
|
||||||
"partialHint": "Some checks were skipped. Allow access to your camera and microphone to test them.",
|
|
||||||
"failed_one": "{{count}} check failed",
|
|
||||||
"failed_other": "{{count}} checks failed",
|
|
||||||
"failedHint": "Open the failed checks below for details, then send the report to your IT department."
|
|
||||||
},
|
|
||||||
"help": {
|
|
||||||
"firewall": "If network tests fail, check your browser permissions and network filtering rules (WebRTC, WebSocket, TURN) with your IT department."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
"legalsTerms": "Legal Notice",
|
"legalsTerms": "Legal Notice",
|
||||||
"data": "Personal Data and Cookies",
|
"data": "Personal Data and Cookies",
|
||||||
"accessibility": "Accessibility: non-compliant",
|
"accessibility": "Accessibility: non-compliant",
|
||||||
"connectionTest": "Test your configuration",
|
|
||||||
"ariaLabel": "new window",
|
"ariaLabel": "new window",
|
||||||
"codeAnnotation": "Our code is open and available on this",
|
"codeAnnotation": "Our code is open and available on this",
|
||||||
"code": "Open Source Code Repository",
|
"code": "Open Source Code Repository",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
||||||
"moreLink": "Learn more",
|
"moreLink": "Learn more",
|
||||||
"moreAbout": "about {{appTitle}}",
|
"moreAbout": "about {{appTitle}}",
|
||||||
"connectionTestLink": "Test your configuration",
|
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Create a meeting for a later date",
|
"laterOption": "Create a meeting for a later date",
|
||||||
"instantOption": "Start an instant meeting"
|
"instantOption": "Start an instant meeting"
|
||||||
|
|||||||
@@ -5,14 +5,6 @@
|
|||||||
"copyLinkTooltip": "Copy link",
|
"copyLinkTooltip": "Copy link",
|
||||||
"resetLabel": "Reset",
|
"resetLabel": "Reset",
|
||||||
"participantLimit": "Up to 150 participants.",
|
"participantLimit": "Up to 150 participants.",
|
||||||
"popupBlocked": "Popup was blocked. Please allow popups for this site.",
|
"popupBlocked": "Popup was blocked. Please allow popups for this site."
|
||||||
"settingsTooltip": "Meeting settings"
|
|
||||||
},
|
|
||||||
"roomSettings": {
|
|
||||||
"title": "Meeting settings",
|
|
||||||
"description": "Configure the meeting {{roomSlug}}.",
|
|
||||||
"notAllowed": "You don't have permission to modify this meeting's settings.",
|
|
||||||
"error": "Unable to load meeting settings.",
|
|
||||||
"closeButton": "Close"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Tester votre configuration",
|
|
||||||
"runTest": "Lancer le test",
|
|
||||||
"runAgain": "Relancer",
|
|
||||||
"cancel": "Annuler",
|
|
||||||
"detailsFor": "Détails de l'étape {{step}}",
|
|
||||||
"downloadReport": "Télécharger le rapport",
|
|
||||||
"homeLink": "Tester votre configuration",
|
|
||||||
"progress": "{{done}}/{{total}}",
|
|
||||||
"progressLabel": "Progression du test de connexion",
|
|
||||||
"groups": {
|
|
||||||
"local": "Navigateur et périphériques",
|
|
||||||
"network": "Connexion au serveur"
|
|
||||||
},
|
|
||||||
"steps": {
|
|
||||||
"browser": "Navigateur",
|
|
||||||
"microphone": "Microphone",
|
|
||||||
"camera": "Caméra",
|
|
||||||
"devices": "Périphériques médias",
|
|
||||||
"websocket": "WebSocket",
|
|
||||||
"webrtc": "WebRTC",
|
|
||||||
"turn": "TURN",
|
|
||||||
"reconnect": "Reconnexion",
|
|
||||||
"selectedCandidate": "Route sélectionnée",
|
|
||||||
"publishAudio": "Publication audio",
|
|
||||||
"publishVideo": "Publication vidéo"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"pending": "En attente",
|
|
||||||
"running": "En cours…",
|
|
||||||
"success": "Réussi",
|
|
||||||
"failed": "Échec",
|
|
||||||
"skipped": "Ignoré"
|
|
||||||
},
|
|
||||||
"counts": {
|
|
||||||
"passed": "réussis",
|
|
||||||
"failed": "en échec",
|
|
||||||
"skipped": "ignorés"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"idle": "Prêt à tester",
|
|
||||||
"idleHint": "Le test dure environ une minute. Votre caméra et votre microphone ne sont utilisés que pendant le test.",
|
|
||||||
"running": "Test en cours…",
|
|
||||||
"runningHint": "Gardez cette page ouverte jusqu'à la fin des vérifications.",
|
|
||||||
"passed": "Tout fonctionne",
|
|
||||||
"passedHint": "Votre navigateur, vos périphériques et votre réseau sont prêts pour une réunion.",
|
|
||||||
"partial": "Test partiel",
|
|
||||||
"partialHint": "Certaines vérifications ont été ignorées. Autorisez l'accès à votre caméra et à votre microphone pour les tester.",
|
|
||||||
"failed_one": "{{count}} vérification en échec",
|
|
||||||
"failed_other": "{{count}} vérifications en échec",
|
|
||||||
"failedHint": "Ouvrez les vérifications en échec pour voir le détail, puis transmettez le rapport à votre service informatique."
|
|
||||||
},
|
|
||||||
"help": {
|
|
||||||
"firewall": "En cas d'échec des tests réseau, vérifiez vos permissions navigateur et les règles de filtrage réseau (WebRTC, WebSocket, TURN) auprès de votre service informatique."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
"legalsTerms": "Mentions légales",
|
"legalsTerms": "Mentions légales",
|
||||||
"data": "Données personnelles et cookie",
|
"data": "Données personnelles et cookie",
|
||||||
"accessibility": "Accessibilité : non conforme",
|
"accessibility": "Accessibilité : non conforme",
|
||||||
"connectionTest": "Tester votre configuration",
|
|
||||||
"ariaLabel": "nouvelle fenêtre",
|
"ariaLabel": "nouvelle fenêtre",
|
||||||
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
|
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
|
||||||
"code": "dépôt de code Open Source",
|
"code": "dépôt de code Open Source",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
||||||
"moreLink": "En savoir plus",
|
"moreLink": "En savoir plus",
|
||||||
"moreAbout": "sur {{appTitle}}",
|
"moreAbout": "sur {{appTitle}}",
|
||||||
"connectionTestLink": "Tester votre configuration",
|
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Créer une réunion pour une date ultérieure",
|
"laterOption": "Créer une réunion pour une date ultérieure",
|
||||||
"instantOption": "Démarrer une réunion instantanée"
|
"instantOption": "Démarrer une réunion instantanée"
|
||||||
|
|||||||
@@ -5,14 +5,6 @@
|
|||||||
"copyLinkTooltip": "Copier le lien",
|
"copyLinkTooltip": "Copier le lien",
|
||||||
"resetLabel": "Réinitialiser",
|
"resetLabel": "Réinitialiser",
|
||||||
"participantLimit": "Jusqu'à 150 participants.",
|
"participantLimit": "Jusqu'à 150 participants.",
|
||||||
"popupBlocked": "La fenêtre pop-up a été bloquée. Veuillez autoriser les pop-ups pour ce site.",
|
"popupBlocked": "La fenêtre pop-up a été bloquée. Veuillez autoriser les pop-ups pour ce site."
|
||||||
"settingsTooltip": "Paramètres de la réunion"
|
|
||||||
},
|
|
||||||
"roomSettings": {
|
|
||||||
"title": "Paramètres de la réunion",
|
|
||||||
"description": "Configurez la réunion {{roomSlug}}.",
|
|
||||||
"notAllowed": "Vous n'avez pas les droits pour modifier les paramètres de cette réunion.",
|
|
||||||
"error": "Impossible de charger les paramètres de la réunion.",
|
|
||||||
"closeButton": "Fermer"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Je configuratie testen",
|
|
||||||
"runTest": "Test starten",
|
|
||||||
"runAgain": "Opnieuw testen",
|
|
||||||
"cancel": "Annuleren",
|
|
||||||
"detailsFor": "Details van stap {{step}}",
|
|
||||||
"downloadReport": "Rapport downloaden",
|
|
||||||
"homeLink": "Je configuratie testen",
|
|
||||||
"progress": "{{done}}/{{total}}",
|
|
||||||
"progressLabel": "Voortgang van de verbindingstest",
|
|
||||||
"groups": {
|
|
||||||
"local": "Browser en apparaten",
|
|
||||||
"network": "Verbinding met de server"
|
|
||||||
},
|
|
||||||
"steps": {
|
|
||||||
"browser": "Browser",
|
|
||||||
"microphone": "Microfoon",
|
|
||||||
"camera": "Camera",
|
|
||||||
"devices": "Media-apparaten",
|
|
||||||
"websocket": "WebSocket",
|
|
||||||
"webrtc": "WebRTC",
|
|
||||||
"turn": "TURN",
|
|
||||||
"reconnect": "Opnieuw verbinden",
|
|
||||||
"selectedCandidate": "Geselecteerde route",
|
|
||||||
"publishAudio": "Audio publiceren",
|
|
||||||
"publishVideo": "Video publiceren"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"pending": "In afwachting",
|
|
||||||
"running": "Bezig…",
|
|
||||||
"success": "Geslaagd",
|
|
||||||
"failed": "Mislukt",
|
|
||||||
"skipped": "Overgeslagen"
|
|
||||||
},
|
|
||||||
"counts": {
|
|
||||||
"passed": "geslaagd",
|
|
||||||
"failed": "mislukt",
|
|
||||||
"skipped": "overgeslagen"
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"idle": "Klaar om te testen",
|
|
||||||
"idleHint": "De test duurt ongeveer één minuut. Je camera en microfoon worden alleen tijdens de test gebruikt.",
|
|
||||||
"running": "Test wordt uitgevoerd…",
|
|
||||||
"runningHint": "Houd deze pagina open totdat alle controles zijn voltooid.",
|
|
||||||
"passed": "Alles werkt",
|
|
||||||
"passedHint": "Je browser, apparaten en netwerk zijn klaar voor een vergadering.",
|
|
||||||
"partial": "Gedeeltelijke test",
|
|
||||||
"partialHint": "Sommige controles zijn overgeslagen. Geef toegang tot je camera en microfoon om deze te testen.",
|
|
||||||
"failed_one": "{{count}} controle mislukt",
|
|
||||||
"failed_other": "{{count}} controles mislukt",
|
|
||||||
"failedHint": "Open de mislukte controles voor meer details en stuur het rapport door naar je IT-afdeling."
|
|
||||||
},
|
|
||||||
"help": {
|
|
||||||
"firewall": "Bij mislukte netwerktests: controleer je browserrechten en netwerkfilterregels (WebRTC, WebSocket, TURN) met je IT-afdeling."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
"legalsTerms": "Wettelijke kennisgeving",
|
"legalsTerms": "Wettelijke kennisgeving",
|
||||||
"data": "Persoonlijke gegevens en cookies",
|
"data": "Persoonlijke gegevens en cookies",
|
||||||
"accessibility": "Toegankelijkheid: audit in uitvoering",
|
"accessibility": "Toegankelijkheid: audit in uitvoering",
|
||||||
"connectionTest": "Test je configuratie",
|
|
||||||
"ariaLabel": "nieuw venster",
|
"ariaLabel": "nieuw venster",
|
||||||
"codeAnnotation": "Onze code is open en beschikbaar op dit",
|
"codeAnnotation": "Onze code is open en beschikbaar op dit",
|
||||||
"code": "Open Source Code Repository",
|
"code": "Open Source Code Repository",
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"moreLinkLabel": "Meer informatie over {{appTitle}} - nieuw tabblad",
|
"moreLinkLabel": "Meer informatie over {{appTitle}} - nieuw tabblad",
|
||||||
"moreLink": "Meer informatie",
|
"moreLink": "Meer informatie",
|
||||||
"moreAbout": "over {{appTitle}}",
|
"moreAbout": "over {{appTitle}}",
|
||||||
"connectionTestLink": "Test je configuratie",
|
|
||||||
"createMenu": {
|
"createMenu": {
|
||||||
"laterOption": "Maak een vergadering voor een latere datum",
|
"laterOption": "Maak een vergadering voor een latere datum",
|
||||||
"instantOption": "Begin direct een vergadering"
|
"instantOption": "Begin direct een vergadering"
|
||||||
|
|||||||
@@ -5,14 +5,6 @@
|
|||||||
"copyLinkTooltip": "Link kopiëren",
|
"copyLinkTooltip": "Link kopiëren",
|
||||||
"resetLabel": "Resetten",
|
"resetLabel": "Resetten",
|
||||||
"participantLimit": "Tot 150 deelnemers.",
|
"participantLimit": "Tot 150 deelnemers.",
|
||||||
"popupBlocked": "Pop-up werd geblokkeerd. Sta pop-ups toe voor deze site.",
|
"popupBlocked": "Pop-up werd geblokkeerd. Sta pop-ups toe voor deze site."
|
||||||
"settingsTooltip": "Vergaderinstellingen"
|
|
||||||
},
|
|
||||||
"roomSettings": {
|
|
||||||
"title": "Vergaderinstellingen",
|
|
||||||
"description": "Configureer de vergadering {{roomSlug}}.",
|
|
||||||
"notAllowed": "Je hebt geen toestemming om de instellingen van deze vergadering te wijzigen.",
|
|
||||||
"error": "De vergaderinstellingen konden niet worden geladen.",
|
|
||||||
"closeButton": "Sluiten"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const StyledSwitch = styled(RACSwitch, {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.571rem',
|
gap: '0.571rem',
|
||||||
color: 'black',
|
color: 'black',
|
||||||
cursor: 'pointer',
|
|
||||||
forcedColorAdjust: 'none',
|
forcedColorAdjust: 'none',
|
||||||
'& .indicator': {
|
'& .indicator': {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -35,29 +34,30 @@ const StyledSwitch = styled(RACSwitch, {
|
|||||||
transitionDelay: '0ms',
|
transitionDelay: '0ms',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'& .checkmark, & .cross': {
|
'& .checkmark': {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
display: 'block',
|
||||||
bottom: 0,
|
top: '50%',
|
||||||
width: '1.313rem', // knob width + 2 × knob margin
|
right: '0.1rem',
|
||||||
display: 'grid',
|
transform: 'translateY(-50%)',
|
||||||
placeItems: 'center',
|
color: 'primary.800',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
'& svg': {
|
|
||||||
display: 'block',
|
|
||||||
width: '0.875rem',
|
|
||||||
height: '0.875rem',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'& .checkmark': {
|
|
||||||
right: 0,
|
|
||||||
color: 'primary.800',
|
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
},
|
},
|
||||||
'& .cross': {
|
'& .cross': {
|
||||||
left: 0,
|
position: 'absolute',
|
||||||
|
display: 'block',
|
||||||
|
top: '50%',
|
||||||
|
left: '0.13rem',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
|
fontSize: '0.70rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: 'opacity 200ms',
|
transition: 'opacity 200ms',
|
||||||
transitionDelay: '0ms',
|
transitionDelay: '0ms',
|
||||||
@@ -80,9 +80,6 @@ const StyledSwitch = styled(RACSwitch, {
|
|||||||
transition: 'opacity 10ms',
|
transition: 'opacity 10ms',
|
||||||
transitionDelay: '0ms',
|
transitionDelay: '0ms',
|
||||||
},
|
},
|
||||||
'&[data-disabled]': {
|
|
||||||
cursor: 'not-allowed',
|
|
||||||
},
|
|
||||||
'&[data-disabled] .indicator': {
|
'&[data-disabled] .indicator': {
|
||||||
borderColor: 'primary.200',
|
borderColor: 'primary.200',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
@@ -114,10 +111,10 @@ export const Switch = ({ children, ...props }: SwitchProps) => (
|
|||||||
<>
|
<>
|
||||||
<div className="indicator">
|
<div className="indicator">
|
||||||
<span className="checkmark" aria-hidden="true">
|
<span className="checkmark" aria-hidden="true">
|
||||||
<RiCheckLine />
|
<RiCheckLine size={16} />
|
||||||
</span>
|
</span>
|
||||||
<span className="cross" aria-hidden="true">
|
<span className="cross" aria-hidden="true">
|
||||||
<RiCloseFill />
|
<RiCloseFill size={16} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{typeof children === 'function' ? children(renderProps) : children}
|
{typeof children === 'function' ? children(renderProps) : children}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const CreatePopup = lazy(() => import('@/features/sdk/routes/CreatePopup'))
|
|||||||
const CreateMeetingButton = lazy(
|
const CreateMeetingButton = lazy(
|
||||||
() => import('@/features/sdk/routes/CreateMeetingButton')
|
() => import('@/features/sdk/routes/CreateMeetingButton')
|
||||||
)
|
)
|
||||||
const SettingsPopup = lazy(() => import('@/features/sdk/routes/SettingsPopup'))
|
|
||||||
const LegalTermsRoute = lazy(
|
const LegalTermsRoute = lazy(
|
||||||
() => import('@/features/legalsTerms/LegalTermsRoute')
|
() => import('@/features/legalsTerms/LegalTermsRoute')
|
||||||
)
|
)
|
||||||
@@ -21,9 +20,6 @@ const AccessibilityRoute = lazy(
|
|||||||
)
|
)
|
||||||
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
|
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
|
||||||
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
|
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
|
||||||
const ConnectionTestRoute = lazy(
|
|
||||||
() => import('@/features/diagnostics/routes/ConnectionTest')
|
|
||||||
)
|
|
||||||
|
|
||||||
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
|
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
|
||||||
|
|
||||||
@@ -31,13 +27,11 @@ export const routes: Record<
|
|||||||
| 'home'
|
| 'home'
|
||||||
| 'room'
|
| 'room'
|
||||||
| 'feedback'
|
| 'feedback'
|
||||||
| 'connectionTest'
|
|
||||||
| 'legalTerms'
|
| 'legalTerms'
|
||||||
| 'accessibility'
|
| 'accessibility'
|
||||||
| 'termsOfService'
|
| 'termsOfService'
|
||||||
| 'sdkCreatePopup'
|
| 'sdkCreatePopup'
|
||||||
| 'sdkCreateButton'
|
| 'sdkCreateButton'
|
||||||
| 'sdkSettingsPopup'
|
|
||||||
| 'recordingDownload',
|
| 'recordingDownload',
|
||||||
{
|
{
|
||||||
name: RouteName
|
name: RouteName
|
||||||
@@ -63,11 +57,6 @@ export const routes: Record<
|
|||||||
path: '/feedback',
|
path: '/feedback',
|
||||||
Component: FeedbackRoute,
|
Component: FeedbackRoute,
|
||||||
},
|
},
|
||||||
connectionTest: {
|
|
||||||
name: 'connectionTest',
|
|
||||||
path: '/test-connection',
|
|
||||||
Component: ConnectionTestRoute,
|
|
||||||
},
|
|
||||||
legalTerms: {
|
legalTerms: {
|
||||||
name: 'legalTerms',
|
name: 'legalTerms',
|
||||||
path: '/mentions-legales',
|
path: '/mentions-legales',
|
||||||
@@ -93,11 +82,6 @@ export const routes: Record<
|
|||||||
path: '/sdk/create-button',
|
path: '/sdk/create-button',
|
||||||
Component: CreateMeetingButton,
|
Component: CreateMeetingButton,
|
||||||
},
|
},
|
||||||
sdkSettingsPopup: {
|
|
||||||
name: 'sdkSettingsPopup',
|
|
||||||
path: '/sdk/settings-popup',
|
|
||||||
Component: SettingsPopup,
|
|
||||||
},
|
|
||||||
recordingDownload: {
|
recordingDownload: {
|
||||||
name: 'recordingDownload',
|
name: 'recordingDownload',
|
||||||
path: /^\/recording\/(?<recordingId>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/,
|
path: /^\/recording\/(?<recordingId>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/,
|
||||||
|
|||||||
@@ -31,19 +31,6 @@ html.font-opendyslexic {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* LiveKit ConnectionCheck appends a temporary <video> to body during publishVideo.
|
|
||||||
Keep it decodable (not display:none) but invisible. */
|
|
||||||
body.connection-test-hide-livekit-video > video {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-4
@@ -1,8 +1,4 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
// Version of @mediapipe/tasks-vision, injected at build time (vite.config.ts).
|
|
||||||
declare const __MEDIAPIPE_VERSION__: string
|
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_BASE_URL: string
|
readonly VITE_API_BASE_URL: string
|
||||||
readonly VITE_APP_TITLE: string
|
readonly VITE_APP_TITLE: string
|
||||||
|
|||||||
@@ -1,27 +1,13 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { visualizer } from 'rollup-plugin-visualizer'
|
import { visualizer } from 'rollup-plugin-visualizer'
|
||||||
import svgr from 'vite-plugin-svgr'
|
import svgr from 'vite-plugin-svgr'
|
||||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||||
|
|
||||||
const mediapipeVersion: string = JSON.parse(
|
|
||||||
readFileSync(
|
|
||||||
new URL(
|
|
||||||
'./node_modules/@mediapipe/tasks-vision/package.json',
|
|
||||||
import.meta.url
|
|
||||||
),
|
|
||||||
'utf-8'
|
|
||||||
)
|
|
||||||
).version
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd())
|
const env = loadEnv(mode, process.cwd())
|
||||||
return {
|
return {
|
||||||
define: {
|
|
||||||
__MEDIAPIPE_VERSION__: JSON.stringify(mediapipeVersion),
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
svgr({
|
svgr({
|
||||||
@@ -37,23 +23,9 @@ export default defineConfig(({ mode }) => {
|
|||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
src: 'node_modules/@mediapipe/tasks-vision/wasm/*',
|
src: 'node_modules/@mediapipe/tasks-vision/wasm/*',
|
||||||
dest: `assets/mediapipe/wasm/${mediapipeVersion}`,
|
dest: 'assets/mediapipe/wasm',
|
||||||
rename: { stripBase: 4 },
|
rename: { stripBase: 4 },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// Kept out of public/ so the instance title reaches the manifest,
|
|
||||||
// the way index.html gets it through %VITE_APP_TITLE%.
|
|
||||||
src: 'site.webmanifest',
|
|
||||||
dest: '.',
|
|
||||||
transform: (content) => {
|
|
||||||
const title = env.VITE_APP_TITLE
|
|
||||||
return JSON.stringify({
|
|
||||||
...JSON.parse(content),
|
|
||||||
name: title,
|
|
||||||
short_name: title,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
env.VITE_ANALYZE === 'true' &&
|
env.VITE_ANALYZE === 'true' &&
|
||||||
|
|||||||
@@ -73,11 +73,6 @@ data:
|
|||||||
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;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.25.1",
|
"version": "1.24.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.25.1",
|
"version": "1.24.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.0",
|
"@html-to/text-cli": "0.6.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.25.1",
|
"version": "1.24.0",
|
||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.25.1",
|
"version": "1.24.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.25.1",
|
"version": "1.24.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.25.1",
|
"version": "1.24.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.25.1"
|
version = "1.24.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
"uvicorn>=0.24.0",
|
"uvicorn>=0.24.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user