Compare commits

..

1 Commits

Author SHA1 Message Date
leo 8bfc0147a5 ♻️(devex) update Makefile lint targets and harmonize service naming
The make lint target did not cover the summary and agents components. Update
the linting workflow to include both services and harmonize Makefile target
names. Harmonize Docker compose user declarations.
2026-08-04 20:34:35 +02:00
77 changed files with 122 additions and 2963 deletions
-11
View File
@@ -8,8 +8,6 @@ and this project adheres to
## [Unreleased]
## [1.25.0] - 2026-08-05
### Added
- ✨(summary) report exception type in failure analytics
@@ -19,9 +17,6 @@ and this project adheres to
- ✨(backend) add roomkit viewset to start a room without WebRTC join
- ✨(frontend) let users set default configuration for generated links
- ✨(frontend) expose media state to external gateways
- ✨(frontend) add connection test feature
- ✨(sdk) allow passing a background color to the calendar iframe
- ✨(sdk) add a room configuration popup from CreateMeetingButton
### Changed
@@ -47,12 +42,6 @@ and this project adheres to
- 🐛(frontend) fall back to user.full_name on request-entry
- 🚸(frontend) show two initials in the Avatar when possible
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
- 🐛(frontend) stop the installed app reopening the room it came from
- 🐛(backend) serialize lazy title in summary payload
- 💄(frontend) show pointer cursor on interactive switches
- 🐛(frontend) fix icon centering in the Switch primitive
- 🐛(frontend) keep Unicode initials intact in avatar
- 🐛(frontend) prevent concurrent settings updates from overwriting each other
## [1.24.0] - 2026-07-21
+33 -13
View File
@@ -39,14 +39,16 @@ DB_PORT = 5432
DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_LINT_BACK = $(COMPOSE_RUN) --no-deps app-dev
COMPOSE_RUN_LINT_AGENTS = $(COMPOSE_RUN) --no-deps multi-user-transcriber-dev
COMPOSE_RUN_LINT_SUMMARY = $(COMPOSE_RUN) --no-deps app-summary-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -59,6 +61,10 @@ LINT_PYLINT = pylint meet demo core
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
LINT_AGENTS = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -198,23 +204,37 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
lint: ## lint back-end python sources
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
lint: ## lint all python sources (back-end, agents, summary)
@$(MAKE) lint-back
@$(MAKE) lint-agents
@$(MAKE) lint-summary
.PHONY: lint
lint-back: ## lint back-end python sources
@$(COMPOSE_RUN_LINT_BACK) sh -c "$(LINT_BACK)"
.PHONY: lint-back
lint-agents: ## lint agents python sources
@$(COMPOSE_RUN_LINT_AGENTS) sh -c "$(LINT_AGENTS)"
.PHONY: lint-agents
lint-summary: ## lint summary python sources
@$(COMPOSE_RUN_LINT_SUMMARY) sh -c "$(LINT_SUMMARY)"
.PHONY: lint-summary
lint-ruff-format: ## format back-end python sources with ruff
@echo 'lint:ruff-format started…'
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_FORMAT)
.PHONY: lint-ruff-format
lint-ruff-check: ## lint back-end python sources with ruff
@echo 'lint:ruff-check started…'
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_CHECK)
.PHONY: lint-ruff-check
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
@echo 'lint:pylint started…'
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
@$(COMPOSE_RUN_LINT_BACK) $(LINT_PYLINT)
.PHONY: lint-pylint
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
+2
View File
@@ -249,6 +249,7 @@ services:
build:
context: ./src/agents
target: development
user: ${DOCKER_USER:-1000}
command: ["python", "metadata_collector.py", "dev"]
env_file:
- env.d/development/metadata_collector
@@ -267,6 +268,7 @@ services:
build:
context: ./src/agents
target: development
user: ${DOCKER_USER:-1000}
env_file:
- env.d/development/multi_user_transcriber
volumes:
-3
View File
@@ -104,6 +104,3 @@ APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY=devKey
APPLICATION_BASE_URL=http://localhost:3000
# Diagnostics
CONNECTION_TEST_ENABLED = True
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.25.0"
version = "1.24.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.25.0"
version = "1.24.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
-1
View File
@@ -65,7 +65,6 @@ def get_frontend_configuration(request):
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"diagnostics": {"connection_test_enabled": settings.CONNECTION_TEST_ENABLED},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
-1
View File
@@ -17,7 +17,6 @@ class FeatureFlag:
"addons": "ADDONS_ENABLED",
"application": "APPLICATION_ENABLED",
"roomkit": "ROOMKIT_ENABLED",
"connection_test": "CONNECTION_TEST_ENABLED",
}
@classmethod
-12
View File
@@ -85,15 +85,3 @@ class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
"""
scope = "roomkit_join"
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated users requesting connection test tokens."""
scope = "connection_test"
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle anonymous users requesting connection test tokens."""
scope = "connection_test"
-73
View File
@@ -2,10 +2,8 @@
# pylint: disable=too-many-lines
import uuid
from datetime import timedelta
from logging import getLogger
from urllib.parse import unquote, urlparse
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
@@ -29,9 +27,6 @@ from rest_framework import (
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
permissions as drf_permissions,
)
from rest_framework import (
response as drf_response,
)
@@ -41,7 +36,6 @@ from rest_framework import (
from rest_framework.settings import api_settings
from core import analytics, enums, models, utils
from core.api import throttling
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
@@ -99,9 +93,7 @@ from core.services.room_roles import (
RoomRoleService,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.utils import generate_token
from ..authentication.livekit import LiveKitTokenAuthentication
from ..models import RoomAccessLevel
@@ -1571,68 +1563,3 @@ class FileViewSet(
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
class DiagnosticsViewSet(viewsets.ViewSet):
"""Endpoints helping users and support diagnose connectivity issues.
Diagnostics are grouped behind a single prefix so upcoming checks
(rtcstats collection, ICE candidate reports, etc.) can be added as new
actions rather than new top-level routes.
They are open to anonymous users: someone who cannot join a room is
exactly who needs to run a test, and they may well not be logged in.
Each action therefore carries its own throttle scope.
"""
permission_classes = [drf_permissions.AllowAny]
@decorators.action(
detail=False,
methods=["POST"],
url_path="connection",
url_name="connection",
throttle_classes=[
throttling.ConnectionTestUserRateThrottle,
throttling.ConnectionTestAnonRateThrottle,
],
)
@FeatureFlag.require("connection_test")
def connection(self, request):
"""Return a short-lived LiveKit token for an ephemeral test room.
Going through the room API is not an option here: it is tied to
registered meetings, lobby rules and longer-lived tokens. Each call
gets its own room so two people testing at the same time never meet.
"""
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid4()}"
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
# is available.
if settings.CELERY_ENABLED:
max_age = (
settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
+ settings.CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS
)
delete_connection_test_room.apply_async(
args=[room],
countdown=max_age,
)
return drf_response.Response(
{
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room,
"token": generate_token(
room=room,
user=request.user,
username="Connection Test",
ttl=timedelta(seconds=expires_in),
),
"expires_in": expires_in,
},
}
)
@@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import aiohttp
@@ -121,7 +121,7 @@ class NotificationService:
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = gettext("Your recording is ready") # Force translation
subject = str(_("Your recording is ready")) # Force translation
try:
send_mail(
@@ -192,7 +192,7 @@ class NotificationService:
"""Generate title from context or return default."""
if recording_datetime is None:
with override(locale):
return gettext("Transcription")
return _("Transcription")
dt = recording_datetime
if owner_timezone:
@@ -137,13 +137,6 @@ class LiveKitEventsService:
room_name = data.room.name or data.egress_info.room_name
if self._is_connection_test_room(room_name):
logger.info(
"Ignoring webhook event for connection test room '%s'.",
room_name,
)
return
if self._filter_regex and not self._filter_regex.search(room_name):
logger.info("Filtered webhook event for room '%s'", room_name)
return
@@ -235,11 +228,6 @@ class LiveKitEventsService:
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
@staticmethod
def _is_connection_test_room(room_name: str) -> bool:
"""Return True for ephemeral rooms created by the connection test endpoint."""
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
@@ -8,7 +8,6 @@ from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
DeleteRoomRequest,
ListRoomsRequest,
TwirpError,
UpdateRoomMetadataRequest,
@@ -89,30 +88,3 @@ class RoomManagement:
finally:
await lkapi.aclose()
@async_to_sync
async def delete_room(self, room_name: str):
"""Delete a LiveKit room and disconnect all participants.
Raises:
RoomNotFoundException: the room does not exist in LiveKit.
RoomManagementException: the deletion otherwise fails.
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
logger.info("Deleted LiveKit room %s", room_name)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping deletion",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception("Unexpected error deleting room %s", room_name)
raise RoomManagementException("Could not delete room") from e
finally:
await lkapi.aclose()
-9
View File
@@ -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",
)
-39
View File
@@ -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
import datetime
import json
import smtplib
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(
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_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
mock_data.event = "unsupported_event"
mock_receive.return_value = mock_data
@@ -824,33 +823,3 @@ def test_receive_filter_processes_matching_events(
service.receive(mock_request)
mock_handle_room_started.assert_called_once()
@mock.patch.object(api.WebhookReceiver, "receive")
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
def test_receive_ignores_connection_test_room(
mock_handle_room_started,
mock_handle_room_finished,
mock_receive,
mock_livekit_config,
settings,
):
"""Should ignore all webhook events for connection test rooms in receive()."""
settings.CONNECTION_TEST_ROOM_PREFIX = "connection-test"
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
mock_data = mock.MagicMock()
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}-{uuid.uuid4()}"
mock_data.event = "room_started"
mock_receive.return_value = mock_data
service = LiveKitEventsService()
service.receive(mock_request)
mock_handle_room_started.assert_not_called()
mock_handle_room_finished.assert_not_called()
@@ -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
-5
View File
@@ -30,11 +30,6 @@ router.register(
addons_viewsets.SessionViewSet,
basename="addons_sessions",
)
router.register(
"diagnostics",
viewsets.DiagnosticsViewSet,
basename="diagnostics",
)
# - External API
external_router = SimpleRouter()
-5
View File
@@ -12,7 +12,6 @@ import mimetypes
import random
import secrets
import string
from datetime import timedelta
from functools import lru_cache
from typing import List, Optional
from uuid import uuid4
@@ -68,7 +67,6 @@ def generate_token( # noqa: PLR0917
sources: Optional[List[str]] = None,
role: Optional[str] = None,
participant_id: Optional[str] = None,
ttl: Optional[timedelta] = None,
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -84,7 +82,6 @@ def generate_token( # noqa: PLR0917
role (Optional[str]): Room's access role if any
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
Returns:
str: The LiveKit JWT access token.
@@ -138,8 +135,6 @@ def generate_token( # noqa: PLR0917
}
)
)
if ttl is not None:
token = token.with_ttl(ttl)
return token.to_jwt()
-31
View File
@@ -354,11 +354,6 @@ class Base(Configuration):
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
environ_prefix=None,
),
"connection_test": values.Value(
default="30/minute",
environ_name="CONNECTION_TEST_THROTTLE_RATES",
environ_prefix=None,
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
@@ -665,30 +660,6 @@ class Base(Configuration):
environ_prefix=None,
default=False,
)
CONNECTION_TEST_ENABLED = values.BooleanValue(
environ_name="CONNECTION_TEST_ENABLED",
environ_prefix=None,
default=False,
)
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
300,
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
environ_prefix=None,
)
# The effective room max age is always computed as
# CONNECTION_TEST_TOKEN_TTL_SECONDS + this value. Token expiration does
# not automatically delete rooms, so once that age is reached, the
# cleanup worker will explicitly delete the room if it still exists.
CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS = values.PositiveIntegerValue(
10,
environ_name="CONNECTION_TEST_ROOM_EXTRA_AGE_SECONDS",
environ_prefix=None,
)
CONNECTION_TEST_ROOM_PREFIX = values.Value(
"connection-test",
environ_name="CONNECTION_TEST_ROOM_PREFIX",
environ_prefix=None,
)
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
@@ -1299,8 +1270,6 @@ class Test(Base):
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
CONNECTION_TEST_ENABLED = True
def __init__(self):
# pylint: disable=invalid-name
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.25.0"
version = "1.24.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.25.0"
version = "1.24.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
-3
View File
@@ -36,9 +36,6 @@ WORKDIR /home/frontend
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ARG VITE_APP_TITLE
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
RUN npm run build
# ---- Front-end image ----
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.25.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.25.0",
"version": "1.24.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.25.0",
"version": "1.24.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+1
View File
@@ -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"}
-18
View File
@@ -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"
}
-3
View File
@@ -45,9 +45,6 @@ export interface ApiConfig {
subtitle: {
enabled: boolean
}
diagnostics: {
connection_test_enabled?: boolean
}
telephony: {
enabled: boolean
international_phone_number?: string
+7 -59
View File
@@ -1,5 +1,5 @@
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
import React, { useLayoutEffect, useMemo } from 'react'
import React from 'react'
const avatar = cva({
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 => {
if (!name) return ''
const words = name.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ''
const first = getFirstGrapheme(words[0])
const second = words.length > 1 ? getFirstGrapheme(words[1]) : ''
return (first + second).toLocaleUpperCase()
const first = words[0].charAt(0)
const second = words.length > 1 ? words[1].charAt(0) : ''
return (first + second).toUpperCase()
}
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
@@ -65,37 +44,7 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
export const Avatar = React.memo(
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
const initials = useMemo(() => getInitials(name), [name])
const textRef = React.useRef<SVGTextElement>(null)
const [offsetY, setOffsetY] = React.useState(0)
// Optically center the initials: measure the ink bounding box of the
// rendered glyphs and shift them so the box's center sits at the middle
// of the viewBox. Works for any font, weight or glyph shape, unlike a
// hand-tuned dy offset. getBBox() is in local (pre-transform)
// coordinates, so applying the translation never changes the measure.
useLayoutEffect(() => {
const text = textRef.current
if (!text) return
const center = () => {
const box = text.getBBox()
// A hidden element measures as an empty box; keep the default then.
if (box.height === 0) return
setOffsetY(50 - (box.y + box.height / 2))
}
center()
// Glyph metrics can change once webfonts finish loading.
let cancelled = false
document.fonts?.ready.then(() => {
if (!cancelled) center()
})
return () => {
cancelled = true
}
}, [initials])
const initials = getInitials(name)
return (
<div
style={{ backgroundColor: bgColor, ...style }}
@@ -108,17 +57,16 @@ export const Avatar = React.memo(
className={css({ width: '100%', height: '100%', display: 'block' })}
>
<text
ref={textRef}
x="50"
y="50"
transform={`translate(0 ${offsetY})`}
dy="-0.08em"
textAnchor="middle"
dominantBaseline="central"
fontSize="52"
fontWeight="500"
fill="currentColor"
>
{initials}
{initials.toUpperCase()}
</text>
</svg>
</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)
}
+3 -16
View File
@@ -18,14 +18,6 @@ export type RoomConfiguration = {
everyone_can_mute?: boolean | null
}
export type ParticipantRole = 'member' | 'administrator' | 'owner'
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
export type ApiResourceAccess = {
id: string
role: ParticipantRole
}
export type ApiRoom = {
id: string
name: string
@@ -35,12 +27,7 @@ export type ApiRoom = {
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: RoomConfiguration
/**
* Only present in the API response when the requesting user is an
* administrator or owner of the room (see RoomSerializer.to_representation
* in the backend). Its presence can therefore be used to detect
* administrability outside of a LiveKit session, where the room_role
* participant attribute is not available.
*/
accesses?: ApiResourceAccess[]
}
export type ParticipantRole = 'member' | 'administrator' | 'owner'
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
@@ -3,12 +3,12 @@ import { fetchApi } from '@/api/fetchApi'
export const fetchRoom = ({
roomId,
username,
username = '',
}: {
roomId: string
username?: string
}) => {
const query = username ? `?username=${encodeURIComponent(username)}` : ''
return fetchApi<ApiRoom>(`/rooms/${roomId}/${query}`)
return fetchApi<ApiRoom>(
`/rooms/${roomId}?username=${encodeURIComponent(username)}`
)
}
@@ -2,8 +2,6 @@ import { type ApiRoom } from './ApiRoom'
import { fetchApi } from '@/api/fetchApi'
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
import type { ApiError } from '@/api/ApiError'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
export type PatchRoomParams = {
roomId: string
@@ -17,25 +15,11 @@ export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
})
}
export const patchRoomMutationKey = ['patchRoom']
export function usePatchRoom(
options?: UseMutationOptions<ApiRoom, ApiError, PatchRoomParams>
) {
return useMutation<ApiRoom, ApiError, PatchRoomParams>({
mutationKey: patchRoomMutationKey,
mutationFn: patchRoom,
onMutate: async ({ roomId, room: partialRoom }) => {
await queryClient.cancelQueries({ queryKey: [keys.room, roomId] })
queryClient.setQueryData<ApiRoom>([keys.room, roomId], (previous) =>
previous ? { ...previous, ...partialRoom } : previous
)
},
onSettled: (_data, _error, { roomId }) => {
if (queryClient.isMutating({ mutationKey: patchRoomMutationKey }) === 1) {
queryClient.invalidateQueries({ queryKey: [keys.room, roomId] })
}
},
...options,
onSuccess: options?.onSuccess,
})
}
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
@@ -205,7 +206,11 @@ export const Admin = () => {
patchRoom({
roomId,
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={[
{
@@ -1,6 +1,8 @@
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useCallback } from 'react'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
export const usePermissionsManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
@@ -21,11 +23,13 @@ export const usePermissionsManager = () => {
everyone_can_mute: enabled,
}
await patchRoom({
const room = await patchRoom({
roomId,
room: { configuration: newConfiguration },
})
queryClient.setQueryData([keys.room, roomId], room)
return { configuration: newConfiguration }
} catch (error) {
console.error('Failed to update muting permission:', error)
@@ -1,5 +1,7 @@
import { RoomEvent, Track } from 'livekit-client'
import { useCallback, useMemo } from 'react'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useConfig } from '@/api/useConfig'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRemoteParticipants } from '@livekit/components-react'
@@ -77,11 +79,13 @@ export const usePublishSourcesManager = () => {
can_publish_sources: newSources,
}
await patchRoom({
const room = await patchRoom({
roomId,
room: { configuration: newConfiguration },
})
queryClient.setQueryData([keys.room, roomId], room)
await updateParticipantsPermissions(
unprivilegedRemoteParticipants,
newSources
@@ -4,7 +4,7 @@ import { Link } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { HStack, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiCloseLine, RiFileCopyLine, RiSettings3Line } from '@remixicon/react'
import { RiCloseLine, RiFileCopyLine } from '@remixicon/react'
import { Text } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { buttonRecipe } from '@/primitives/buttonRecipe'
@@ -37,49 +37,15 @@ const CreateMeetingButton = () => {
initialRoom
)
const [isRoomCreatedInSession, setIsRoomCreatedInSession] = useState(false)
const showSettingsButton =
isRoomCreatedInSession || searchParams.get('settings') === 'true'
const { data } = useRoomCreationCallback({ callbackId })
const roomUrl = useMemo(() => {
if (room?.slug) return getRouteUrl('room', room.slug)
}, [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(() => {
if (!data?.room?.slug) return
setRoom(data.room)
setIsRoomCreatedInSession(true)
setCallbackId(undefined)
setIsPending(false)
popupManager.sendRoomData({
@@ -95,7 +61,6 @@ const CreateMeetingButton = () => {
(id) => setCallbackId(id),
(data) => {
setRoom(data)
setIsRoomCreatedInSession(true)
setIsPending(false)
}
)
@@ -105,7 +70,6 @@ const CreateMeetingButton = () => {
const resetState = () => {
setRoom(undefined)
setIsRoomCreatedInSession(false)
setCallbackId(undefined)
setIsPending(false)
popupManager.clearState()
@@ -114,27 +78,20 @@ const CreateMeetingButton = () => {
if (isPending) {
return (
<div
style={{
backgroundColor: backgroundColor,
height: '100%',
}}
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<Spinner size={34} />
<Button
variant="quaternaryText"
square
icon={<RiCloseLine />}
onPress={resetState}
aria-label={t('resetLabel')}
/>
</div>
<Spinner size={34} />
<Button
variant="quaternaryText"
square
icon={<RiCloseLine />}
onPress={resetState}
aria-label={t('resetLabel')}
/>
</div>
)
}
@@ -147,8 +104,6 @@ const CreateMeetingButton = () => {
justifyContent: 'start',
alignItems: 'start',
border: 'none',
backgroundColor: backgroundColor,
height: '100%',
}}
>
{roomUrl && room?.slug ? (
@@ -166,25 +121,14 @@ const CreateMeetingButton = () => {
{t('joinButton')}
</Link>
<HStack gap={0}>
{showSettingsButton && (
<Button
variant="quaternaryText"
square
icon={<RiSettings3Line />}
aria-label={t('settingsTooltip')}
onPress={() => {
popupManager.createSettingsPopupWindow(room.slug, () => {})
}}
/>
)}
<Button
variant="quaternaryText"
square
icon={<RiFileCopyLine />}
tooltip={t('copyLinkTooltip')}
onPress={() => {
navigator.clipboard.writeText(roomUrl)
}}
aria-label={t('copyLinkTooltip')}
/>
{searchParams.get('readOnly') === 'false' && (
<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
private messageParent(type: ClientMessageType, data: any) {
window?.parent.postMessage(
-12
View File
@@ -266,18 +266,6 @@ export const Footer = () => {
{t('links.accessibility')}
</Link>
</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>
<A
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."
}
}
-1
View File
@@ -36,7 +36,6 @@
"legalsTerms": "Rechtliche Hinweise",
"data": "Datenschutz und Cookies",
"accessibility": "Barrierefreiheit: nicht konform",
"connectionTest": "Testen Sie Ihre Konfiguration",
"ariaLabel": "neues Fenster",
"codeAnnotation": "Unser Code ist offen und verfügbar in diesem",
"code": "Open-Source-Repository",
-1
View File
@@ -13,7 +13,6 @@
"moreLinkLabel": "Mehr über {{appTitle}} erfahren neuer Tab",
"moreLink": "Mehr erfahren",
"moreAbout": "über {{appTitle}}",
"connectionTestLink": "Testen Sie Ihre Konfiguration",
"createMenu": {
"laterOption": "Meeting für später planen",
"instantOption": "Meeting sofort starten"
+1 -9
View File
@@ -5,14 +5,6 @@
"copyLinkTooltip": "Link kopieren",
"resetLabel": "Zurücksetzen",
"participantLimit": "Bis zu 150 Teilnehmende.",
"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"
"popupBlocked": "Popup wurde blockiert. Bitte erlaube Popups für diese Website."
}
}
@@ -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."
}
}
-1
View File
@@ -36,7 +36,6 @@
"legalsTerms": "Legal Notice",
"data": "Personal Data and Cookies",
"accessibility": "Accessibility: non-compliant",
"connectionTest": "Test your configuration",
"ariaLabel": "new window",
"codeAnnotation": "Our code is open and available on this",
"code": "Open Source Code Repository",
-1
View File
@@ -13,7 +13,6 @@
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
"moreLink": "Learn more",
"moreAbout": "about {{appTitle}}",
"connectionTestLink": "Test your configuration",
"createMenu": {
"laterOption": "Create a meeting for a later date",
"instantOption": "Start an instant meeting"
+1 -9
View File
@@ -5,14 +5,6 @@
"copyLinkTooltip": "Copy link",
"resetLabel": "Reset",
"participantLimit": "Up to 150 participants.",
"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"
"popupBlocked": "Popup was blocked. Please allow popups for this site."
}
}
@@ -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."
}
}
-1
View File
@@ -36,7 +36,6 @@
"legalsTerms": "Mentions légales",
"data": "Données personnelles et cookie",
"accessibility": "Accessibilité : non conforme",
"connectionTest": "Tester votre configuration",
"ariaLabel": "nouvelle fenêtre",
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
"code": "dépôt de code Open Source",
-1
View File
@@ -13,7 +13,6 @@
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
"moreLink": "En savoir plus",
"moreAbout": "sur {{appTitle}}",
"connectionTestLink": "Tester votre configuration",
"createMenu": {
"laterOption": "Créer une réunion pour une date ultérieure",
"instantOption": "Démarrer une réunion instantanée"
+1 -9
View File
@@ -5,14 +5,6 @@
"copyLinkTooltip": "Copier le lien",
"resetLabel": "Réinitialiser",
"participantLimit": "Jusqu'à 150 participants.",
"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"
"popupBlocked": "La fenêtre pop-up a été bloquée. Veuillez autoriser les pop-ups pour ce site."
}
}
@@ -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."
}
}
-1
View File
@@ -35,7 +35,6 @@
"legalsTerms": "Wettelijke kennisgeving",
"data": "Persoonlijke gegevens en cookies",
"accessibility": "Toegankelijkheid: audit in uitvoering",
"connectionTest": "Test je configuratie",
"ariaLabel": "nieuw venster",
"codeAnnotation": "Onze code is open en beschikbaar op dit",
"code": "Open Source Code Repository",
-1
View File
@@ -13,7 +13,6 @@
"moreLinkLabel": "Meer informatie over {{appTitle}} - nieuw tabblad",
"moreLink": "Meer informatie",
"moreAbout": "over {{appTitle}}",
"connectionTestLink": "Test je configuratie",
"createMenu": {
"laterOption": "Maak een vergadering voor een latere datum",
"instantOption": "Begin direct een vergadering"
+1 -9
View File
@@ -5,14 +5,6 @@
"copyLinkTooltip": "Link kopiëren",
"resetLabel": "Resetten",
"participantLimit": "Tot 150 deelnemers.",
"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"
"popupBlocked": "Pop-up werd geblokkeerd. Sta pop-ups toe voor deze site."
}
}
+19 -22
View File
@@ -12,7 +12,6 @@ const StyledSwitch = styled(RACSwitch, {
alignItems: 'center',
gap: '0.571rem',
color: 'black',
cursor: 'pointer',
forcedColorAdjust: 'none',
'& .indicator': {
position: 'relative',
@@ -35,29 +34,30 @@ const StyledSwitch = styled(RACSwitch, {
transitionDelay: '0ms',
},
},
'& .checkmark, & .cross': {
'& .checkmark': {
position: 'absolute',
top: 0,
bottom: 0,
width: '1.313rem', // knob width + 2 × knob margin
display: 'grid',
placeItems: 'center',
display: 'block',
top: '50%',
right: '0.1rem',
transform: 'translateY(-50%)',
color: 'primary.800',
fontSize: '0.75rem',
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 1,
'& svg': {
display: 'block',
width: '0.875rem',
height: '0.875rem',
},
},
'& .checkmark': {
right: 0,
color: 'primary.800',
opacity: 0,
},
'& .cross': {
left: 0,
position: 'absolute',
display: 'block',
top: '50%',
left: '0.13rem',
transform: 'translateY(-50%)',
color: 'white',
fontSize: '0.70rem',
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 1,
opacity: 1,
transition: 'opacity 200ms',
transitionDelay: '0ms',
@@ -80,9 +80,6 @@ const StyledSwitch = styled(RACSwitch, {
transition: 'opacity 10ms',
transitionDelay: '0ms',
},
'&[data-disabled]': {
cursor: 'not-allowed',
},
'&[data-disabled] .indicator': {
borderColor: 'primary.200',
background: 'transparent',
@@ -114,10 +111,10 @@ export const Switch = ({ children, ...props }: SwitchProps) => (
<>
<div className="indicator">
<span className="checkmark" aria-hidden="true">
<RiCheckLine />
<RiCheckLine size={16} />
</span>
<span className="cross" aria-hidden="true">
<RiCloseFill />
<RiCloseFill size={16} />
</span>
</div>
{typeof children === 'function' ? children(renderProps) : children}
-16
View File
@@ -9,7 +9,6 @@ const CreatePopup = lazy(() => import('@/features/sdk/routes/CreatePopup'))
const CreateMeetingButton = lazy(
() => import('@/features/sdk/routes/CreateMeetingButton')
)
const SettingsPopup = lazy(() => import('@/features/sdk/routes/SettingsPopup'))
const LegalTermsRoute = lazy(
() => import('@/features/legalsTerms/LegalTermsRoute')
)
@@ -21,9 +20,6 @@ const AccessibilityRoute = lazy(
)
const RoomRoute = lazy(() => import('@/features/rooms/routes/Room'))
const FeedbackRoute = lazy(() => import('@/features/rooms/routes/Feedback'))
const ConnectionTestRoute = lazy(
() => import('@/features/diagnostics/routes/ConnectionTest')
)
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
@@ -31,13 +27,11 @@ export const routes: Record<
| 'home'
| 'room'
| 'feedback'
| 'connectionTest'
| 'legalTerms'
| 'accessibility'
| 'termsOfService'
| 'sdkCreatePopup'
| 'sdkCreateButton'
| 'sdkSettingsPopup'
| 'recordingDownload',
{
name: RouteName
@@ -63,11 +57,6 @@ export const routes: Record<
path: '/feedback',
Component: FeedbackRoute,
},
connectionTest: {
name: 'connectionTest',
path: '/test-connection',
Component: ConnectionTestRoute,
},
legalTerms: {
name: 'legalTerms',
path: '/mentions-legales',
@@ -93,11 +82,6 @@ export const routes: Record<
path: '/sdk/create-button',
Component: CreateMeetingButton,
},
sdkSettingsPopup: {
name: 'sdkSettingsPopup',
path: '/sdk/settings-popup',
Component: SettingsPopup,
},
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})$/,
-13
View File
@@ -31,19 +31,6 @@ html.font-opendyslexic {
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;
}
-14
View File
@@ -26,20 +26,6 @@ export default defineConfig(({ mode }) => {
dest: 'assets/mediapipe/wasm',
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' &&
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.25.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.25.0",
"version": "1.24.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.25.0",
"version": "1.24.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.25.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.25.0",
"version": "1.24.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.25.0",
"version": "1.24.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.25.0"
version = "1.24.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",