Compare commits

..

45 Commits

Author SHA1 Message Date
lebaudantoine 18ef94dc10 wip fix build 2026-08-10 13:05:17 +02:00
lebaudantoine a9be74a5a4 wip try triggering a request permission on toggle 2026-08-10 13:04:14 +02:00
lebaudantoine dff8c5696e fixup! wip soundtester 2026-08-10 12:56:57 +02:00
lebaudantoine db1010ce29 fixup! wip add an audio gauge in the input menu 2026-08-10 12:29:07 +02:00
lebaudantoine ded3e17dcb fixup! wip add an audio gauge in the input menu 2026-08-10 12:07:25 +02:00
lebaudantoine 977abadcc0 wip handle permission handling in join tracks 2026-08-10 11:37:45 +02:00
lebaudantoine d559d53deb wip reorganize join 2026-08-10 00:16:09 +02:00
lebaudantoine ac5da2a3df fixup! wip refactor the lobby in a dedicated component 2026-08-09 23:34:38 +02:00
lebaudantoine 7efc6b3fdd fixup! huge refacto of the permissions handling + fix various regression 2026-08-09 23:22:22 +02:00
lebaudantoine 1684ef85ec wip refactor the lobby in a dedicated component 2026-08-09 23:21:47 +02:00
lebaudantoine 38530facba wip soundtester 2026-08-09 16:17:08 +02:00
lebaudantoine 1fca767855 wip add an audio gauge in the input menu 2026-08-09 16:17:08 +02:00
lebaudantoine 71ea6421dd wip sync again devices preferences 2026-08-09 16:17:08 +02:00
lebaudantoine e93bc91b24 revert hot fix now the permission should be synced 2026-08-09 16:17:08 +02:00
lebaudantoine d4df0df027 huge refacto of the permissions handling + fix various regression
/**
 * Keeps permissionsStore aligned with the browser. The store is a cache,
 * never a second source of truth: every signal re-reads the browser via
 * syncPermissions() (single writer).
 *
 * Re-sync triggers, all event-driven — no polling:
 *  - devicechange: granting permission reveals device labels/ids, so this
 *    fires on grant in every browser INCLUDING Safari — replacing the
 *    previous 500ms Safari polling. (A denial is caught by the concurrent
 *    getUserMedia rejection → notePermissionDeniedFromGum.)
 *  - window focus: returning from the browser/system permission UI.
 *  - Permissions API 'change' events, where the query is supported.
 */
2026-08-09 16:17:07 +02:00
lebaudantoine ddcc4062b9 wip remove hook that's failing 2026-08-09 15:48:56 +02:00
lebaudantoine c7889c14b5 wip observe media device error on room 2026-08-09 15:48:56 +02:00
lebaudantoine dd4a104e3a wip remove exact constraint 2026-08-09 15:48:55 +02:00
lebaudantoine 7514bff7c1 wip try to encapsulate captureEvent calls in telemetry 2026-08-09 15:48:55 +02:00
lebaudantoine 993efec027 wip encapsulate exception capture 2026-08-09 15:48:55 +02:00
lebaudantoine 46a4955fe0 🐛(frontend) drop resize listener in useIsMobileBrowser
`isMobileBrowser()` only reads `navigator.userAgent`, which does
not change during the lifetime of the document, so the previous
`resize` listener never had anything meaningful to update.

It did, however, dispatch `setIsMobile` on components rendered into
a Document Picture-in-Picture window (e.g. the reactions toolbar).
When the PiP window had already been closed, Firefox threw
"can't access dead object".

Compute the value once and skip the listener entirely.

Fix 019cb315-d827-73f2-b1cc-74e4dd71e982
2026-08-07 12:03:10 +02:00
lebaudantoine b01a46b345 🐛(frontend) gate blur on WebGL2 transformer support
`ProcessorWrapper.isSupported` reports pipeline support but not
whether the WebGL2 transformer is available. On browsers where it
is not (e.g. Chrome/Edge on Windows with WebGL2 disabled by a GPU
blocklist), toggling blur throws at runtime.

Update `supportsBackgroundProcessors()` to check both, so the UI
only exposes blur when it can actually run.

fix 019f8e3b-f035-73e2-9d6a-d0dd2d0a1163
2026-08-07 12:03:10 +02:00
lebaudantoine 45e1a68fec 🐛(frontend) guard getRouteUrl('room', slug) against missing slug
InviteDialog.tsx and Info.tsx were the last call sites calling
getRouteUrl('room', slug) without a slug guard, unlike every other
caller (e.g. useCopyRoomToClipboard).

Compute roomUrl only when the slug exists (undefined in
InviteDialog, '' in Info to keep its unguarded .replace safe).
Guarding at the call site preserves the "no room data yet" state
instead of returning a bogus "/" URL from room.to.

Fix 019fd616-f158-7771-8cff-bac3090b8449
2026-08-07 12:03:10 +02:00
lebaudantoine 261ab8c7cf 🐛(frontend) unmount PiP portal synchronously on pagehide
When the PiP window closes, the browser destroys its document right
after `pagehide`. If the portal unmount is left to React's async
scheduling, it commits against a dead document and `removeChild`
throws "NotFoundError", crashing the app.

Subscribe `PictureInPicturePortal` to the Valtio store with
`sync: true`, and use `flushSync` in `usePictureInPicture` on
teardown so React unmounts the portal while the PiP document is
still alive.

Fix 019f42cf-86a9-7ad2-8e64-81b004ddc5de
2026-08-07 12:03:10 +02:00
lebaudantoine 7022becc78 🐛(frontend) normalize thrown values into proper Error instances
LiveKit can surface raw DOM events (for example WebSocket "error"
events, whose only enumerable key is `isTrusted`) instead of Error
instances.

When such a value ends up being captured, our error reporting logs
it as "Event: Event captured as exception with keys: isTrusted",
which is unhelpful and hides the real cause.

Add a small helper that normalizes any unknown thrown or emitted
value into a proper Error, preserving the original payload as
context.

Fixes 01997b9a-db63-7fc2-8fe4-f21dd7fd608d.
2026-08-06 17:30:26 +02:00
lebaudantoine e0ff28ed48 🔖(patch) release 1.25.22 2026-08-06 13:29:17 +02:00
lebaudantoine 61e8b597dc 🐛(frontend) harmonize cache configuration for MediaPipe assets
The wasm and js files shipped by MediaPipe were served with
different cache policies, which could leave the two out of sync on
the client (fresh js with stale wasm, or vice versa).

Align the cache configuration across the MediaPipe assets so they
are always cached and invalidated together.
2026-08-06 13:16:37 +02:00
lebaudantoine f3626a2dc6 🐛(frontend) serve MediaPipe assets under a versioned path
The MediaPipe assets were served under /assets, where the cache
behavior differs between wasm and js files. As a result, clients
could end up with a fresh js loader paired with a stale wasm binary
(or vice versa), leaving MediaPipe out of sync.

Copy the assets under a versioned route so the URL changes whenever
the dependency version bumps. Clients then reload both the js and
the wasm together, keeping them in sync.
2026-08-06 13:16:37 +02:00
lebaudantoine 41e937c1f1 🔖(patch)) release 1.25.1 2026-08-06 11:31:08 +02:00
lebaudantoine d988c72208 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch 2026-08-06 11:27:26 +02:00
lebaudantoine b96d591db3 🔖(minor) bump release to 1.25.0 2026-08-05 18:12:42 +02:00
lebaudantoine bffc51ac4c 🐛(frontend) add trailing slash on the fetch-room URL
The fetch-room URL was missing its trailing slash, which caused the
backend to issue a 301 redirect. Query parameters were being dropped
in the process, leading to incorrect requests.

Append the trailing slash so the request hits the correct endpoint
directly, without going through a redirect.
2026-08-05 18:04:52 +02:00
lebaudantoine 22a1713c60 🐛(frontend) fix concurrent PATCH races on room settings
Rapid toggles could persist a stale configuration: each PATCH
replaces the full room config, and every call site built it from a
render-time snapshot. A toggle issued before the previous one
resolved therefore overwrote the newer value with an older one.

Handle the cache centrally in usePatchRoom so the next toggle always
reads an up-to-date configuration.
2026-08-05 17:23:48 +02:00
lebaudantoine c23f449520 🐛(frontend) stop passing username as a query param when undefined
Skip adding the username query parameter when its value is
undefined, so the request URL no longer ends up with an
`?username=undefined` (or similar) that the backend has to handle.
2026-08-05 17:23:48 +02:00
lebaudantoine 0536896373 (sdk) add a room configuration popup from CreateMeetingButton
Introduce a room configuration popup opened from the SDK's
CreateMeetingButton, laid out like the Google Meet "call options"
dialog: logo header, grey section bands, and a footer bar with the
close action.

Like CreatePopup, it runs in a dedicated popup window so it can
access session cookies, which would be blocked in an iframe. If the
user is not authenticated, they are redirected to login and come
back to this popup afterwards.

Permissions are enforced server-side. The room is fetched with the
user's session, and settings are only shown when the room is
administrable by this user. Since #1482 removed the
is_administrable flag from the room serializer (roles now live in
the LiveKit participant attributes, only available in-meeting),
administrability is detected here through the presence of the
`accesses` field, which the backend only serializes for
administrators and owners. The PATCH endpoint enforces the same
permissions server-side regardless.

The settings mirror the in-room Admin panel. Unlike the Admin panel,
there is no LiveKit connection here, so changes are only persisted
in the room configuration (and applied when a session starts):
participants of an ongoing session are not live-synced or notified.
2026-08-05 17:23:48 +02:00
lebaudantoine f49c61d9bf (sdk) allow passing a background color to the calendar iframe
Let integrators pass a custom background color to the iframe used by
the calendar SDK, so it can match the surrounding product's theme.
2026-08-05 17:23:48 +02:00
lebaudantoine b593516802 🔒️(backend) derive connection-test room max age from token TTL
Refactor CONNECTION_TEST_ROOM_MAX_AGE_SECONDS so it is no longer an
independent setting but a quantity derived from (or added on top of)
the token TTL.

This prevents a misconfiguration where the token would outlive the
delete-room callback. In that case, an attacker holding a valid
token could recreate the room after the callback fired and escape
the intended cleanup.
2026-08-05 15:33:13 +02:00
Arnaud Robin 1328098c45 🐛(frontend) keep Unicode initials intact in avatar
Some characters span multiple UTF-16 code units. Taking a naive first
index for avatar initials can split them and show a broken glyph when
the camera is off.

Optically fix initials centering with a more complex approach.
2026-08-05 14:47:29 +02:00
lebaudantoine 58205f81d4 🐛(frontend) fix icon centering in the Switch primitive
Icons inside the Switch primitive were not properly centered.

Use relative sizes for the icons and switch to a grid-based
placement strategy so they stay centered regardless of the switch
size.
2026-08-05 13:47:50 +02:00
lebaudantoine b7892431be 💄(frontend) show pointer cursor on interactive switches
Set the cursor to a pointer on Switch components when they are
actually interactive, so it is visually clear that they can be
toggled.
2026-08-05 13:47:50 +02:00
lebaudantoine 00a2bd9558 🐛(backend) serialize lazy title in summary payload
_generate_title returned a lazy gettext_lazy proxy in the
recording_datetime is None branch, which json.dumps cannot
serialize.

This crashed requests.post(json=payload) with "Object of type
__proxy__ is not JSON serializable" whenever the LiveKit egress
lookup failed (started_at=None).

Force evaluation with a non-lazy method.

Add a regression test asserting the v2 payload is a real str and
is JSON-serializable when timestamps are unavailable.
The existing without_metadata test missed this: mocked
requests.post never serialized, and a lazy proxy compares equal
to its string.
2026-08-05 12:48:45 +02:00
lebaudantoine bc003f928e ⚗️(frontend) add candidate pair diagnostic to WebRTC checks
Add a custom diagnostic step that reports which ICE candidate pair
was selected on the WebRTC connection, as well as all working pairs
observed during the check.

Experimental and vibe-coded for now; the output is meant to help
debugging and will likely be revisited.
2026-08-05 12:16:40 +02:00
Arnaud Robin d756825fd7 (frontend) add connection test feature
Introduce a new connection test page to allow users to verify
their device and network compatibility with the application.
The feature also supports generating and downloading a detailed report
of the test results.
2026-08-05 12:16:40 +02:00
Arnaud Robin b01a47bfd7 (backend) add connection-test API
Currently users have no way to reliably test their connection before
joining a room. To address this, we plan to build a connection-test
page.

The testing requires a dedicated LiveKit token, issued without going
through the room API, which is tied to registered meetings, lobby
rules, and longer-lived access tokens.

Introduce a new viewset for all diagnostics-related features. The
first route issues a token for diagnostics, even for anonymous
users. Each request creates a new dedicated room so users never
share the same LiveKit room during tests. Tokens are short-lived
(default 10 minutes) to limit reuse, and the endpoint is throttled
to prevent abuse.

A Celery worker also schedules a callback that deletes the room
after a certain delay, in every case.
2026-08-05 12:16:40 +02:00
davd-gzl 06e73d7a5e 🐛(frontend) stop the installed app reopening the room it came from
site.webmanifest declared no start_url, so the page that linked
it became one and an install started inside a room reopened that
room on every launch. It now declares "/", moves out of public/
and takes VITE_APP_TITLE for name and short_name, which shipped
empty and leaned on the browser falling back to the title.

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

Some files were not shown because too many files have changed in this diff Show More