Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 8972c8dbb8 wip allow authenticated user to launch recording/transcription 2026-08-05 17:27:32 +02:00
129 changed files with 1767 additions and 3876 deletions
-78
View File
@@ -10,84 +10,6 @@ and this project adheres to
### Added
- ✨(backend) add OpenShift-compatible recording download endpoint
### Changed
- ✨(backend) accept form-urlencoded on the user token endpoint
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
### Fixed
- 📝(docs) fix minor typos in comments and docstrings
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
## [1.27.0] - 2026-08-14
### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
### Fixed
- 📈(frontend) downgrade unreachable external home URL from error to event
- 🐛(frontend) handle 401 responses when syncing user preferences
- 🐛(frontend) harden speaker test against missing sinks and play errors
- 🐛(frontend) implement hysteresis band for the control bar layout
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
- 🐛(frontend) stop reporting screen-share denials as errors
- 🐛(frontend) generalize screen-share error modal beyond macOS
- 📈(frontend) stop double-reporting media device failures
## [1.26.0] - 2026-08-12
### Added
- 📈(frontend) capture media diagnostics on media errors
- ✨(frontend) add an audio gauge to the microphone select menu
- ✨(frontend) add a sound tester to the output select menu
- ✨(frontend) prompt for permissions when toggling a denied device
- ⚗️(frontend) capture console.error in PostHog
- 📈(frontend) snapshot media devices on the happy path
- 🚸(frontend) guide users when the OS blocks browser media access
- ✨(frontend) add a silent-microphone watcher on join and room screens
### Changed
- ♻️(frontend) encapsulate error tracking behind a telemetry module
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
- 🔧(frontend) sync persisted device ids with the actual selected devices
- 💄(frontend) hide the ProConnect button on narrow viewports
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
### Fixed
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
- 🐛(frontend) fix permission store regression
- 🐛(frontend) handle missing device errors gracefully
- 🐛(frontend) display the meeting id in the join screen page title
## [1.25.2] - 2026-08-06
### Fixed
- 🐛(frontend) serve MediaPipe assets under a versioned path
- 🐛(frontend) harmonize cache configuration for MediaPipe assets
## [1.25.1] - 2026-08-06
### Fixed
- 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch
## [1.25.0] - 2026-08-05
### Added
- ✨(summary) report exception type in failure analytics
- ✨(frontend) add configurable documentation menu item
- ✨(frontend) allow promoting authenticated participants
+1 -1
View File
@@ -4,7 +4,7 @@
Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly by submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
We appreciate your effort to make Visio more secure.
-5
View File
@@ -65,11 +65,6 @@ 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;
-27
View File
@@ -50,24 +50,10 @@ paths:
The application must be authorized for the user's email domain.
The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
Request parameters may be sent either as "application/x-www-form-urlencoded"
(as specified by RFC 6749 for OAuth 2.0 token endpoints) or as "application/json".
operationId: generateToken
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TokenRequest'
examples:
tokenRequest:
summary: Request token for user delegation
value:
client_id: "550e8400-e29b-41d4-a716-446655440000"
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type: "client_credentials"
scope: "user@example.com"
application/json:
schema:
$ref: '#/components/schemas/TokenRequest'
@@ -131,19 +117,6 @@ paths:
summary: Domain not authorized
value:
error: "This application is not authorized for this email domain."
'415':
description: |
Unsupported media type. The request body must be sent as
"application/x-www-form-urlencoded" or "application/json".
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
unsupportedMediaType:
summary: Unsupported request content type
value:
detail: 'Unsupported media type "text/plain" in request.'
/rooms:
get:
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.27.0"
version = "1.24.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.27.0"
version = "1.24.0"
source = { virtual = "." }
dependencies = [
{ name = "livekit-agents" },
+34
View File
@@ -198,3 +198,37 @@ class IsPresentInMeeting(permissions.BasePermission):
return False
except ParticipantsManagementException:
return False
class RoomCapabilityPermission(permissions.BasePermission):
"""Wip."""
capability = None
def has_object_permission(self, request, view, obj):
"""Wip."""
user = request.user
if not user or not user.is_authenticated:
return False
if obj.is_administrator_or_owner(user):
return True
if not obj.configuration.get(self.capability, True):
return False
try:
return ParticipantsManagement().check_if_in_meeting(
room_name=str(obj.pk), identity=str(user.sub)
)
except ParticipantNotFoundException:
return False
except ParticipantsManagementException:
return False
class CanRecord(RoomCapabilityPermission):
"""Wip."""
capability = "authenticated_can_record"
+1
View File
@@ -349,6 +349,7 @@ class RoomConfiguration(BaseModel):
can_publish_sources: list[TrackSource] | None = None
everyone_can_mute: bool | None = None
authenticated_can_record: bool | None = None
model_config = {"extra": "forbid"}
+27 -98
View File
@@ -12,13 +12,12 @@ from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.files.storage import default_storage
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import Http404, StreamingHttpResponse
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from botocore.exceptions import BotoCoreError, ClientError
from django_filters import rest_framework as django_filters
from rest_framework import (
decorators,
@@ -114,14 +113,6 @@ from .feature_flag import FeatureFlag
logger = getLogger(__name__)
class StorageUnavailable(drf_exceptions.APIException):
"""Exception raised when recording storage cannot be reached."""
status_code = drf_status.HTTP_503_SERVICE_UNAVAILABLE
default_detail = "The recording storage is temporarily unavailable."
default_code = "storage_unavailable"
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
@@ -405,7 +396,7 @@ class RoomViewSet(
methods=["post"],
url_path="start-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.CanRecord,
],
)
@FeatureFlag.require("recording")
@@ -430,11 +421,30 @@ class RoomViewSet(
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
)
if room.is_administrator_or_owner(request.user):
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
)
else:
# todo - encapsulate it in a clear method which replicate privileges role on obj
accesses = models.ResourceAccess.objects.filter(
resource=room,
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
).values_list("user_id", "role")
models.RecordingAccess.objects.bulk_create(
[
models.RecordingAccess(
user_id=user_id,
role=role,
recording=recording,
)
for user_id, role in accesses
]
)
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
@@ -478,7 +488,7 @@ class RoomViewSet(
methods=["post"],
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.CanRecord,
],
)
@FeatureFlag.require("recording")
@@ -1229,87 +1239,6 @@ class RecordingViewSet(
return drf_response.Response("authorized", headers=request.headers, status=200)
@decorators.action(detail=False, methods=["get"], url_path="download")
def media_download(self, request, *args, **kwargs):
"""
Stream a recording file directly from S3 to the browser.
This endpoint is registered at /media/recordings/<uuid>.<ext> in core/urls.py
and handles the download flow on OpenShift where the Nginx auth_request
mechanism is not available.
Django acts as a streaming proxy: bytes flow from S3 (internal cluster URL)
directly to the client response. This avoids any page navigation or redirect,
so the browser triggers a native download without leaving the current page.
Access control mirrors media_auth: the user must have the "retrieve" ability
on the recording and the recording must be in a saved state.
"""
recording_id = self.kwargs.get("recording_id")
extension = self.kwargs.get("extension")
if extension not in [file.value for file in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this id.") from e
if extension != recording.extension:
raise drf_exceptions.NotFound("No recording found with this extension.")
abilities = recording.get_abilities(request.user)
if not abilities["retrieve"]:
logger.debug(
"User '%s' lacks permission for recording download", request.user.id
)
raise drf_exceptions.PermissionDenied()
if not recording.is_saved:
logger.debug("Recording '%s' has not been saved", recording)
raise drf_exceptions.PermissionDenied()
s3_client = default_storage.connection.meta.client
try:
s3_object = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
)
except ClientError as error:
error_code = error.response.get("Error", {}).get("Code")
if error_code in {"404", "NoSuchKey", "NoSuchObject"}:
raise drf_exceptions.NotFound(
"No recording file found in storage."
) from error
logger.exception("Unable to retrieve recording from storage")
raise StorageUnavailable() from error
except BotoCoreError as error:
logger.exception("Unable to retrieve recording from storage")
raise StorageUnavailable() from error
content_type = s3_object.get("ContentType") or "video/mp4"
content_length = s3_object.get("ContentLength")
filename = f"{recording_id}.{extension}"
body = s3_object["Body"]
def stream():
try:
yield from body.iter_chunks(chunk_size=65536)
finally:
body.close()
streaming_response = StreamingHttpResponse(
stream(),
content_type=content_type,
)
streaming_response["Content-Disposition"] = f'attachment; filename="{filename}"'
if content_length:
streaming_response["Content-Length"] = content_length
return streaming_response
# pylint: disable=too-many-public-methods
class FileViewSet(
@@ -12,9 +12,6 @@ from rest_framework import decorators, mixins, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
from rest_framework import (
parsers as drf_parsers,
)
from rest_framework import (
response as drf_response,
)
@@ -44,7 +41,6 @@ class ApplicationViewSet(viewsets.ViewSet):
methods=["post"],
url_path="token",
url_name="token",
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
)
@FeatureFlag.require("application")
def generate_jwt_access_token(self, request, *args, **kwargs):
@@ -3,12 +3,8 @@ Test recordings API endpoints in the Meet core app: retrieve.
"""
import random
from unittest import mock
from django.core.files.storage import default_storage
import pytest
from botocore.exceptions import ClientError, EndpointConnectionError
from freezegun import freeze_time
from rest_framework.test import APIClient
@@ -18,11 +14,6 @@ from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
def media_recording_url(recording):
"""Return the direct media URL for a recording."""
return f"/media/recordings/{recording.id!s}.{recording.extension}"
def test_api_recording_retrieve_anonymous():
"""Anonymous users should not be able to retrieve recordings."""
recording = RecordingFactory()
@@ -34,9 +25,6 @@ def test_api_recording_retrieve_anonymous():
"detail": "Authentication credentials were not provided."
}
response = client.get(media_recording_url(recording))
assert response.status_code == 401
def test_api_recording_retrieve_authenticated():
"""Authenticated users without access receive 404 when requesting recordings.
@@ -56,9 +44,6 @@ def test_api_recording_retrieve_authenticated():
assert response.status_code == 404
assert response.json() == {"detail": "No Recording matches the given query."}
response = client.get(media_recording_url(recording))
assert response.status_code == 403
def test_api_recording_retrieve_members():
"""
@@ -78,11 +63,6 @@ def test_api_recording_retrieve_members():
"detail": "You do not have permission to perform this action."
}
recording.status = RecordingStatusChoices.SAVED
recording.save(update_fields=["status"])
response = client.get(media_recording_url(recording))
assert response.status_code == 403
def test_api_recording_retrieve_administrators(settings):
"""A user who is an administrator of a recording should be able to retrieve it."""
@@ -157,78 +137,6 @@ def test_api_recording_retrieve_owners(settings):
}
@pytest.mark.parametrize("role", ["owner", "administrator"])
def test_api_recording_media_download_authorized(role):
"""Owners and administrators can download a saved recording."""
user = UserFactory()
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(recording=recording, user=user, role=role)
client = APIClient()
client.force_login(user)
body = mock.Mock(iter_chunks=mock.Mock(return_value=[b"recording content"]))
s3_object = {
"Body": body,
"ContentType": "video/mp4",
"ContentLength": len(b"recording content"),
}
with mock.patch.object(
default_storage.connection.meta.client,
"get_object",
return_value=s3_object,
) as get_object:
response = client.get(media_recording_url(recording))
assert response.status_code == 200
get_object.assert_called_once_with(
Bucket=default_storage.bucket_name,
Key=recording.key,
)
assert response["Content-Type"] == "video/mp4"
assert response["Content-Disposition"] == (
f'attachment; filename="{recording.id}.{recording.extension}"'
)
assert b"".join(response.streaming_content) == b"recording content"
body.close.assert_called_once_with()
def test_api_recording_media_download_missing_object():
"""A missing recording object should return 404."""
user = UserFactory()
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
error = ClientError(
{"Error": {"Code": "NoSuchKey", "Message": "The object does not exist."}},
"GetObject",
)
with mock.patch.object(
default_storage.connection.meta.client, "get_object", side_effect=error
):
response = client.get(media_recording_url(recording))
assert response.status_code == 404
def test_api_recording_media_download_storage_unavailable():
"""A storage connection failure should return 503."""
user = UserFactory()
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
error = EndpointConnectionError(endpoint_url="https://storage.test")
with mock.patch.object(
default_storage.connection.meta.client, "get_object", side_effect=error
):
response = client.get(media_recording_url(recording))
assert response.status_code == 503
@freeze_time("2023-01-15 12:00:00")
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"""Test that the API returns the correct expiration date for a non-expired recording."""
@@ -5,7 +5,6 @@ Tests for external API /token endpoint
# pylint: disable=W0621
from unittest import mock
from urllib.parse import urlencode
import jwt
import pytest
@@ -89,155 +88,6 @@ def test_api_applications_generate_token_success(settings):
}
def test_api_applications_generate_token_form_urlencoded(settings):
"""The token endpoint should accept "application/x-www-form-urlencoded"
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
token endpoints, so that standard OAuth 2.0 client libraries work
out of the box."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
(
f"client_id={application.client_id}"
f"&client_secret={plain_secret}"
"&grant_type=client_credentials"
"&scope=user%40example.com"
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
response.data.pop("access_token")
assert response.data == {
"token_type": "Bearer",
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": "rooms:list rooms:create",
}
def test_api_applications_generate_token_form_urlencoded_invalid_credentials():
"""Invalid credentials sent as form-urlencoded should be parsed and
rejected with 401, proving the request body is properly decoded."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_form_urlencoded_missing_fields():
"""Missing required fields in a form-urlencoded request should return
a 400 validation error, like for JSON requests."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode({"grant_type": "client_credentials"}),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
for field in ("client_id", "client_secret", "scope"):
assert field in response.data
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
"""An unsupported grant_type sent as form-urlencoded should return 400."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "authorization_code",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
assert "grant_type" in response.data
def test_api_applications_generate_token_form_urlencoded_special_characters():
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
client_secret should survive form-urlencoded decoding."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
plain_secret = "s3cr3t&with=special+chars%42"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user@example.com",
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
def test_api_applications_generate_token_unsupported_media_type():
"""Content types other than JSON and form-urlencoded should still be
rejected with 415 Unsupported Media Type."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
"client_id=x&client_secret=y&grant_type=client_credentials&scope=a@b.co",
content_type="text/plain",
)
assert response.status_code == 415
def test_api_applications_generate_token_invalid_client_id():
"""Invalid client_id should return 401."""
user = UserFactory(email="user@example.com")
-7
View File
@@ -60,13 +60,6 @@ urlpatterns = [
]
),
),
# The request is handled by RecordingViewSet.media_download which validates
# access rights and streams the recording file directly from MinIO to the browser.
path(
"media/recordings/<uuid:recording_id>.<slug:extension>",
viewsets.RecordingViewSet.as_view({"get": "media_download"}),
name="recording_media_download",
),
]
if settings.EXTERNAL_API_ENABLED:
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.27.0"
version = "1.24.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+4 -4
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.27.0"
version = "1.24.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -2252,11 +2252,11 @@ wheels = [
[[package]]
name = "sqlparse"
version = "0.6.0"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
-5
View File
@@ -4,11 +4,6 @@ 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)$ {
+89 -76
View File
@@ -1,40 +1,41 @@
{
"name": "meet",
"version": "1.27.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.27.0",
"version": "1.24.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.23",
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@mediapipe/tasks-vision": "0.10.35",
"@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.6",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.21.0",
"posthog-js": "1.404.1",
"livekit-client": "2.20.0",
"posthog-js": "1.395.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.10",
"react-i18next": "17.0.8",
"react-stately": "3.48.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
@@ -742,28 +743,28 @@
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
@@ -966,12 +967,12 @@
"license": "GPL-3.0+"
},
"node_modules/@livekit/components-core": {
"version": "0.12.14",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
"integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
"version": "0.12.13",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
"license": "Apache-2.0",
"dependencies": {
"@floating-ui/dom": "1.7.6",
"@floating-ui/dom": "1.7.4",
"loglevel": "1.9.1",
"rxjs": "7.8.2"
},
@@ -979,17 +980,17 @@
"node": ">=18"
},
"peerDependencies": {
"livekit-client": "^2.20.1",
"livekit-client": "^2.17.2",
"tslib": "^2.6.2"
}
},
"node_modules/@livekit/components-react": {
"version": "2.9.23",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
"integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
"version": "2.9.21",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/components-core": "0.12.14",
"@livekit/components-core": "0.12.13",
"clsx": "2.1.1",
"events": "^3.3.0",
"jose": "^6.0.12",
@@ -999,8 +1000,8 @@
"node": ">=18"
},
"peerDependencies": {
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0",
"livekit-client": "^2.20.1",
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
"livekit-client": "^2.18.2",
"react": ">=18",
"react-dom": ">=18",
"tslib": "^2.6.2"
@@ -1027,9 +1028,9 @@
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.50.4",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
"version": "1.46.6",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
@@ -1048,12 +1049,18 @@
"livekit-client": "^1.12.0 || ^2.1.0"
}
},
"node_modules/@mediapipe/tasks-vision": {
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
"version": "0.10.14",
"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",
@@ -1720,18 +1727,18 @@
"license": "MIT"
},
"node_modules/@posthog/core": {
"version": "1.48.3",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
"version": "1.39.5",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz",
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==",
"license": "MIT",
"dependencies": {
"@posthog/types": "^1.405.0"
"@posthog/types": "^1.392.0"
}
},
"node_modules/@posthog/types": {
"version": "1.405.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
"version": "1.392.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz",
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==",
"license": "MIT"
},
"node_modules/@react-aria/overlays": {
@@ -4393,14 +4400,11 @@
}
},
"node_modules/core-js": {
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"version": "3.39.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
@@ -4712,6 +4716,15 @@
"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",
@@ -6403,9 +6416,9 @@
}
},
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [
{
"type": "individual",
@@ -6422,7 +6435,7 @@
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
@@ -8112,20 +8125,20 @@
"license": "MIT"
},
"node_modules/livekit-client": {
"version": "2.21.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.50.4",
"@livekit/protocol": "1.46.6",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "9.0.6"
"webrtc-adapter": "9.0.5"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
@@ -9123,17 +9136,17 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.404.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
"integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
"license": "(Apache-2.0 AND MIT)",
"version": "1.395.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@posthog/core": "^1.43.1",
"@posthog/types": "^1.397.0",
"core-js": "^3.49.0",
"@posthog/core": "^1.38.0",
"@posthog/types": "^1.391.1",
"core-js": "^3.38.1",
"dompurify": "^3.3.2",
"fflate": "^0.4.8",
"preact": "^10.29.3",
"preact": "^10.29.2",
"query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.3.0"
}
@@ -9425,9 +9438,9 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
@@ -9437,7 +9450,7 @@
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
@@ -11648,9 +11661,9 @@
"license": "Apache-2.0"
},
"node_modules/webrtc-adapter": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
+8 -7
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.27.0",
"version": "1.24.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -19,29 +19,30 @@
"@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.23",
"@livekit/components-react": "2.9.21",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14",
"@mediapipe/tasks-vision": "0.10.35",
"@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.6",
"i18next": "26.3.1",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.21.0",
"posthog-js": "1.404.1",
"livekit-client": "2.20.0",
"posthog-js": "1.395.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.10",
"react-i18next": "17.0.8",
"react-stately": "3.48.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
+13 -24
View File
@@ -2,31 +2,24 @@ 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'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
export const SoundTester = () => {
const { t } = useTranslation('settings')
const [isPlaying, setIsPlaying] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
const { devices, activeDeviceId } = useMediaDeviceSelect({
kind: 'audiooutput',
})
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
useEffect(() => {
if (!canTestAudioOutput() || !activeDeviceId) return
if (!devices.some((device) => device.deviceId === activeDeviceId)) return
audioRef.current?.setSinkId(activeDeviceId).catch((error) => {
if (error instanceof DOMException && error.name === 'NotFoundError') {
return
const updateActiveId = async (deviceId: string) => {
try {
await audioRef?.current?.setSinkId(deviceId)
} catch (error) {
console.error(`Error setting sinkId: ${error}`)
}
reportError(
'device_switch_failure',
new Error(`Error setting sinkId: ${error}`)
)
})
}, [devices, activeDeviceId])
}
updateActiveId(activeDeviceId)
}, [activeDeviceId])
// prevent pausing the sound
navigator.mediaSession.setActionHandler('pause', function () {})
@@ -35,13 +28,9 @@ export const SoundTester = () => {
<>
<Button
variant="secondaryText"
onPress={async () => {
try {
await audioRef?.current?.play()
setIsPlaying(true)
} catch {
setIsPlaying(false)
}
onPress={() => {
audioRef?.current?.play()
setIsPlaying(true)
}}
size="sm"
isDisabled={isPlaying}
@@ -55,7 +44,7 @@ export const SoundTester = () => {
{/* eslint-disable jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src="/sounds/uprise.mp3"
src="sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)}
/>
</>
@@ -1,24 +0,0 @@
import type { CaptureResult } from 'posthog-js'
const IGNORED_EXCEPTION_PATTERNS = [
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
]
const shouldIgnoreException = (value: unknown): boolean =>
typeof value === 'string' &&
IGNORED_EXCEPTION_PATTERNS.some((pattern) => pattern.test(value))
export const filterExceptions = (
event: CaptureResult | null
): CaptureResult | null => {
if (event?.event !== '$exception') return event
const exceptionList = event.properties?.['$exception_list']
const values: unknown[] = Array.isArray(exceptionList)
? exceptionList.map((exception) => exception?.value)
: []
values.push(event.properties?.['$exception_message'])
return values.some(shouldIgnoreException) ? null : event
}
@@ -1,8 +1,15 @@
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'
import { getPosthog } from '../utils'
import { filterExceptions } from '../exceptionFilters'
let posthog: PostHog | null = null
const getPosthog = async () => {
if (!posthog) posthog = (await import('posthog-js')).default
return posthog
}
export const startAnalyticsSession = (data: ApiUser) => {
getPosthog().then((ph) => {
@@ -31,6 +38,7 @@ export const useAnalytics = ({
flags_api_host,
isDisabled,
}: useAnalyticsProps) => {
const [location] = useLocation()
const { user } = useUser()
useEffect(() => {
@@ -41,14 +49,6 @@ export const useAnalytics = ({
api_host: host,
flags_api_host: flags_api_host,
person_profiles: 'always',
capture_pageview: 'history_change',
capture_pageleave: true,
capture_exceptions: {
capture_unhandled_errors: true,
capture_unhandled_rejections: true,
capture_console_errors: true,
},
before_send: filterExceptions,
})
})
}, [id, host, flags_api_host, isDisabled])
@@ -58,5 +58,12 @@ export const useAnalytics = ({
startAnalyticsSession(user)
}, [user])
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
useEffect(() => {
getPosthog().then((ph) => {
ph.capture('$pageview')
})
}, [location])
return null
}
@@ -1,150 +0,0 @@
import { getPosthog } from './utils'
export const captureEvent = (
event: string,
props?: Record<string, unknown>
) => {
void getPosthog()
.then((ph) => {
ph.capture(event, props)
})
.catch(() => {
/* telemetry must never break the app */
})
if (import.meta.env.DEV) {
console.warn(`[telemetry] ${event}`, props)
}
}
export type LogCode =
// media
| 'join_preview_failure'
| 'room_media_failure'
| 'livekit_room_error'
| 'device_switch_failure'
| 'permission_poll_failure'
| 'media_devices_error_event'
// non-media families
| 'participant_mute_api_failure'
| 'permissions_api_failure'
| 'effects_processor_failure'
| 'clipboard_failure'
| 'fullscreen_failure'
| 'publish_sources_failure'
| 'disconnect_failure'
| 'generic_failure'
export const reportError = (
logCode: LogCode,
error: unknown,
extraInfo: Record<string, unknown> = {}
): void => {
const e = error instanceof Error ? error : new Error(String(error))
void getPosthog()
.then((ph) => {
ph.captureException(e, {
log_code: logCode,
error_name: e.name,
error_message: e.message,
...extraInfo,
})
})
.catch(() => {})
if (import.meta.env.DEV) {
console.warn(`[${logCode}]`, e, extraInfo)
}
}
export interface DeviceSnapshot {
cam_count: number
mic_count: number
out_count: number
labels_visible: boolean
saved_cam_present: boolean | null
saved_mic_present: boolean | null
saved_video_device_id_set: boolean
saved_audio_device_id_set: boolean
audio_enabled: boolean | null
video_enabled: boolean | null
cam_permission: PermissionState | 'unknown'
mic_permission: PermissionState | 'unknown'
}
/** Reads the persisted LiveKit user choices without importing the store. */
const readPersistedChoices = (): {
videoDeviceId?: string
audioDeviceId?: string
videoEnabled?: boolean
audioEnabled?: boolean
} => {
try {
return JSON.parse(localStorage.getItem('lk-user-choices') ?? '{}')
} catch {
return {}
}
}
const queryPermission = async (
name: 'camera' | 'microphone'
): Promise<PermissionState | 'unknown'> => {
try {
const status = await navigator.permissions.query({
name: name as PermissionName,
})
return status.state
} catch {
return 'unknown'
}
}
export const deviceSnapshot = async (): Promise<DeviceSnapshot> => {
const choices = readPersistedChoices()
let devices: MediaDeviceInfo[] = []
try {
devices = await navigator.mediaDevices.enumerateDevices()
} catch {
/* snapshot stays partial */
}
const ofKind = (k: MediaDeviceKind) => devices.filter((d) => d.kind === k)
const present = (k: MediaDeviceKind, id?: string) =>
id ? ofKind(k).some((d) => d.deviceId === id) : null
const [cam_permission, mic_permission] = await Promise.all([
queryPermission('camera'),
queryPermission('microphone'),
])
return {
cam_count: ofKind('videoinput').length,
mic_count: ofKind('audioinput').length,
out_count: ofKind('audiooutput').length,
labels_visible: devices.some((d) => !!d.label),
saved_cam_present: present('videoinput', choices.videoDeviceId),
saved_mic_present: present('audioinput', choices.audioDeviceId),
saved_video_device_id_set: !!choices.videoDeviceId,
saved_audio_device_id_set: !!choices.audioDeviceId,
audio_enabled: choices.audioEnabled ?? null,
video_enabled: choices.videoEnabled ?? null,
cam_permission,
mic_permission,
}
}
export const captureMediaEvent = async (
event:
| 'media-device-error'
| 'media-acquisition'
| 'media-device-topology'
| 'media-device-success'
| 'device-not-found'
| 'permissions-denied'
| 'screen-share-permission-denied'
| 'silent-mic-detected'
| 'silent-mic-analyser-unavailable'
| 'silent-mic-recovered'
| 'visit-room'
| 'connection-event',
props: Record<string, unknown>
) => {
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
}
@@ -1,8 +0,0 @@
import type { PostHog } from 'posthog-js'
let posthog: PostHog | null = null
export const getPosthog = async () => {
if (!posthog) posthog = (await import('posthog-js')).default
return posthog
}
@@ -6,8 +6,6 @@ import { queryClient } from '@/api/queryClient'
import { updateUserPreferences } from './updateUserPreferences'
import { convertToBackendLanguage } from '@/utils/languages'
import { useUser } from './useUser'
import { ApiError } from '@/api/ApiError.ts'
import { reportError } from '@/features/analytics/telemetry'
/**
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
@@ -44,11 +42,6 @@ export const useSyncUserPreferencesWithBackend = () => {
}
}
syncBrowserPreferencesToBackend().catch((error) => {
if (error instanceof ApiError && error.statusCode === 401) return
reportError('generic_failure', error, {
context: '[useSyncUserPreferencesWithBackend] Failed to sync:',
})
})
syncBrowserPreferencesToBackend()
}, [i18n.language, isLoggedIn, user, mutateAsync])
}
@@ -1,6 +1,6 @@
import { ChatRow } from '@/stores/chat'
import React, { useMemo } from 'react'
import { formatChatMessageLinks } from '../utils'
import { formatChatMessageLinks } from '@livekit/components-react'
import { css } from '@/styled-system/css'
import { Text } from '@/primitives'
@@ -23,7 +23,7 @@ export const ChatProvider = () => {
resetChatStore()
}, [])
// Trigger the message notification (temporary)
// Tigger the message notification (temporary)
useEffect(() => {
// TEMPORARY: This is a brittle workaround that relies on message count tracking
// due to recent LiveKit useChat changes breaking the previous implementation
-32
View File
@@ -1,32 +0,0 @@
import { tokenize, createDefaultGrammar } from '@livekit/components-core'
import { ReactNode } from 'react'
const defaultGrammar = Object.freeze(createDefaultGrammar())
export function formatChatMessageLinks(message: string): ReactNode {
const trimmedMessage = message.replace(/^[\r\n]+|[\r\n]+$/g, '')
return tokenize(trimmedMessage, defaultGrammar).map((tok, i) => {
if (typeof tok === `string`) {
return tok
} else {
const content = tok.content.toString()
const href =
tok.type === `url`
? /^http(s?):\/\//.test(content)
? content
: `https://${content}`
: `mailto:${content}`
return (
<a
className="lk-chat-link"
key={i}
href={href}
target="_blank"
rel="noreferrer"
>
{content}
</a>
)
}
})
}
@@ -15,7 +15,6 @@ import { css } from '@/styled-system/css'
import { useConfig } from '@/api/useConfig'
import { LoginButton } from '@/components/LoginButton'
import { LoadingScreen } from '@/components/LoadingScreen'
import { captureEvent } from '@/features/analytics/telemetry'
const Columns = ({ children }: { children?: ReactNode }) => {
return (
@@ -161,11 +160,7 @@ const Home = () => {
window.location.replace(data.external_home_url)
} catch (error) {
setRedirectFailed(true)
captureEvent('external-home-unreachable', {
error_name: error instanceof Error ? error.name : 'Unknown',
error_message:
error instanceof Error ? error.message : String(error),
})
console.error('Site is not reachable:', error)
}
}
}
@@ -6,7 +6,7 @@ import type { NotificationType } from '@/features/notifications/NotificationType
// fixme - handle dynamic audio output changes
export const useNotificationSound = () => {
const notificationsSnap = useSnapshot(notificationsStore)
const [play] = useSound('/sounds/notifications.mp3', {
const [play] = useSound('./sounds/notifications.mp3', {
sprite: {
participantJoined: [0, 1150],
handRaised: [1400, 180],
@@ -4,7 +4,6 @@ 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(
@@ -56,9 +55,7 @@ export const decodeNotificationDataReceived = (
return parsed as NotificationPayload
} catch (error) {
// Handle errors appropriately for your application
reportError('generic_failure', error, {
context: 'Failed to decode notification payload:',
})
console.error('Failed to decode notification payload:', error)
return
}
}
@@ -1,6 +1,5 @@
import type { Participant } from 'livekit-client'
import { useLowerHandParticipant } from './lowerHandParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useLowerHandParticipants = () => {
const { lowerHandParticipant } = useLowerHandParticipant()
@@ -12,9 +11,7 @@ export const useLowerHandParticipants = () => {
)
return Promise.all(promises)
} catch (error) {
reportError('generic_failure', error, {
context: 'An error occurred while lowering hands :',
})
console.error('An error occurred while lowering hands :', error)
throw new Error('An error occurred while lowering hands.', {
cause: error,
})
@@ -1,7 +1,6 @@
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()
@@ -23,11 +22,8 @@ export const useParticipantRole = () => {
}),
})
} catch (error) {
reportError(
'generic_failure',
new Error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
console.error(
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
@@ -10,7 +10,6 @@ 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
@@ -88,7 +87,7 @@ export const useWaitingParticipants = () => {
await refetchWaiting()
} catch (e) {
reportError('generic_failure', e)
console.error(e)
setListEnabled(true)
}
}
@@ -7,9 +7,7 @@ import { useEffect, useMemo } from 'react'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore, {
sync: true,
})
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
const container = useMemo(() => {
return pipStoreSnap?.window?.document.getElementById('root')
@@ -21,7 +19,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
}
}, [])
if (!container || !container.isConnected) return null
if (!container) return null
return createPortal(
/**
@@ -1,9 +1,7 @@
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
@@ -61,29 +59,21 @@ export const usePictureInPicture = () => {
if (!IS_PIP_SUPPORTED) return null
if (isOpen) return null
let pipWindow: Window
try {
pipWindow =
const pipWindow =
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).documentPictureInPicture.requestWindow({
width,
height,
})
} catch {
// Avoid unhandled rejections if the user blocks or closes the request.
return null
}
try {
initializeTitleAndLanguage(pipWindow, t('title'))
initializePortalContainer(pipWindow)
syncStyles(pipWindow)
const cleanUp = () => {
if (documentPictureInPictureStore.window === pipWindow) {
flushSync(() => {
documentPictureInPictureStore.window = null
})
documentPictureInPictureStore.window = null
}
}
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
@@ -92,10 +82,8 @@ export const usePictureInPicture = () => {
})
documentPictureInPictureStore.window = ref(pipWindow)
} catch (error) {
reportError('generic_failure', error, {
context: 'pip_init_failure',
})
pipWindow.close()
// Avoid unhandled rejections if the user blocks or closes the request.
console.error('Failed to open Picture-in-Picture window', error)
return null
}
},
@@ -26,8 +26,8 @@ const StyledContainer = styled('div', {
backgroundColor: 'primaryDark.100',
maxWidth: '100%',
opacity: 0,
translate: '0 3.25rem',
transition: 'opacity, translate',
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
@@ -36,7 +36,7 @@ const StyledContainer = styled('div', {
isVisible: {
true: {
opacity: 1,
translate: '0 0',
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
shouldBeCenteredWithToggleButton,
setShouldBeCenteredWithToggleButton,
] = useState(false)
const [offsetX, setOffsetX] = useState(0)
const [rightOffset, setRightOffset] = useState(0)
const updateArrows = useCallback(() => {
const el = scrollRef.current
@@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({
useLayoutEffect(() => {
if (!shouldBeCenteredWithToggleButton || isMobile) {
setOffsetX(0)
setRightOffset(0)
return
}
@@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({
const containerCenterX = containerRect.left + containerRect.width / 2
const shift = toggleCenterX - containerCenterX
if (Math.abs(shift) < 0.5) return
setOffsetX((prev) => prev + shift)
setRightOffset((prev) => prev - shift * 2)
}
const schedule = () => {
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
isVisible={isVisible}
style={
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
? { transform: `translateX(${offsetX}px)` }
? { marginRight: `${rightOffset}px` }
: { margin: '0 15px' }
}
>
@@ -98,6 +98,7 @@ export const NoAccessView = ({
)}
</Text>
</VStack>
{/*todo - update the message here to adapt to room configuration */}
{!isLoggedIn && (
<LoginPrompt
heading={t(`${i18nKey}.login.heading`)}
@@ -16,6 +16,7 @@ import {
notifyRecordingSaveInProgress,
useNotifyParticipants,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
@@ -28,7 +29,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'
import { useCanRecord } from '@/features/recording/hooks/useCanRecord'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -38,6 +39,7 @@ export const ScreenRecordingSidePanel = () => {
const [includeTranscript, setIncludeTranscript] = useState(false)
const canRecord = useCanRecord()
const isAdminOrOwner = useIsAdminOrOwner()
const hasScreenRecordingAccess = useHasRecordingAccess(
@@ -63,7 +65,7 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingRequested,
})
captureEvent('screen-recording-requested', {})
posthog.capture('screen-recording-requested', {})
}
const handleScreenRecording = async () => {
@@ -100,19 +102,17 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
captureEvent('screen-recording-started', {
posthog.capture('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
}
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to handle recording:',
})
console.error('Failed to handle recording:', error)
}
}
if (!isAdminOrOwner) {
if (!canRecord) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
@@ -6,7 +6,7 @@ import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasRecordingAccess,
useHasFeatureWithoutAdminRights,
useHasFeatureWithoutRecordingRights,
useRecordingStatuses,
} from '../index'
import { useState } from 'react'
@@ -17,6 +17,7 @@ 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'
@@ -34,7 +35,6 @@ 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()
@@ -53,7 +53,7 @@ export const TranscriptSidePanel = () => {
FeatureFlags.Transcript
)
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
const hasFeatureWithoutAdminRights = useHasFeatureWithoutRecordingRights(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionRequested,
})
captureEvent('transcript-requested', {})
posthog.capture('transcript-requested', {})
}
const handleTranscript = async () => {
@@ -121,15 +121,13 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
captureEvent('transcript-started', {
posthog.capture('transcript-started', {
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
}
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to handle transcript:',
})
console.error('Failed to handle transcript:', error)
}
}
@@ -0,0 +1,15 @@
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useUser } from '@/features/auth/api/useUser'
export const useCanRecord = () => {
const apiRoomData = useRoomData()
const isAdminOrOwner = useIsAdminOrOwner()
const { isLoggedIn } = useUser()
return (
isAdminOrOwner ||
(!!isLoggedIn &&
apiRoomData?.configuration?.authenticated_can_record !== false)
)
}
@@ -2,21 +2,21 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { RecordingMode } from '../types'
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useCanRecord } from './useCanRecord'
import type { FeatureFlags } from '@/features/analytics/enums'
export const useHasFeatureWithoutAdminRights = (
export const useHasFeatureWithoutRecordingRights = (
mode: RecordingMode,
featureFlag: FeatureFlags
) => {
const featureEnabled = useFeatureFlagEnabled(featureFlag)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
const isAdminOrOwner = useIsAdminOrOwner()
const canRecord = useCanRecord()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isRecordingModeEnabled &&
!isAdminOrOwner
!canRecord
)
}
@@ -2,8 +2,8 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import type { RecordingMode } from '../types'
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import type { FeatureFlags } from '@/features/analytics/enums'
import { useCanRecord } from './useCanRecord'
export const useHasRecordingAccess = (
mode: RecordingMode,
@@ -12,11 +12,11 @@ export const useHasRecordingAccess = (
const featureEnabled = useFeatureFlagEnabled(featureFlag)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
const isAdminOrOwner = useIsAdminOrOwner()
const canRecord = useCanRecord()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
canRecord &&
isRecordingModeEnabled
)
}
@@ -1,6 +1,5 @@
import { useRoomInfo } from '@livekit/components-react'
import { useMemo } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const useRoomMetadata = () => {
const { metadata } = useRoomInfo()
@@ -9,9 +8,7 @@ export const useRoomMetadata = () => {
try {
return JSON.parse(metadata)
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to parse room metadata:',
})
console.error('Failed to parse room metadata:', error)
return undefined
}
} else {
+1 -1
View File
@@ -1,7 +1,7 @@
// hooks
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
export { useHasFeatureWithoutRecordingRights } from './hooks/useHasFeatureWithoutRecordingRights.ts'
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
// api
@@ -16,6 +16,7 @@ export enum ApiAccessLevel {
export type RoomConfiguration = {
can_publish_sources?: Source[] | null
everyone_can_mute?: boolean | null
authenticated_can_record?: boolean | null
}
export type ParticipantRole = 'member' | 'administrator' | 'owner'
@@ -10,5 +10,5 @@ export const fetchRoom = ({
}) => {
const query = username ? `?username=${encodeURIComponent(username)}` : ''
return fetchApi<ApiRoom>(`/rooms/${roomId}/${query}`)
return fetchApi<ApiRoom>(`/rooms/${roomId}${query}`)
}
@@ -9,7 +9,6 @@ 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()
@@ -32,10 +31,7 @@ export const useMuteParticipant = () => {
// Guard against undefined token for non-admin users
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
reportError(
'participant_mute_api_failure',
new Error('Cannot mute participant: missing auth token')
)
console.error('Cannot mute participant: missing auth token')
return
}
@@ -57,11 +53,8 @@ export const useMuteParticipant = () => {
}
)
} catch (error) {
reportError(
'participant_mute_api_failure',
new Error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
return
}
@@ -72,11 +65,8 @@ export const useMuteParticipant = () => {
destinationIdentities: [participant.identity],
})
} catch (e) {
reportError(
'participant_mute_api_failure',
new Error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
console.error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
}
@@ -1,6 +1,5 @@
import type { Participant } from 'livekit-client'
import { useMuteParticipant } from './muteParticipant'
import { reportError } from '@/features/analytics/telemetry'
export const useMuteParticipants = () => {
const { muteParticipant } = useMuteParticipant()
@@ -12,9 +11,7 @@ export const useMuteParticipants = () => {
)
return Promise.all(promises)
} catch (error) {
reportError('participant_mute_api_failure', error, {
context: 'An error occurred while muting participants :',
})
console.error('An error occurred while muting participants :', error)
throw new Error('An error occurred while muting participants.', {
cause: error,
})
@@ -1,7 +1,6 @@
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 = () => {
@@ -33,11 +32,8 @@ export const useParticipantPermissions = () => {
}),
})
} catch (error) {
reportError(
'permissions_api_failure',
new Error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
console.error(
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
@@ -1,6 +1,5 @@
import type { Participant, Track } from 'livekit-client'
import { useParticipantPermissions } from './updateParticipantPermissions'
import { reportError } from '@/features/analytics/telemetry'
type Source = Track.Source
export const useUpdateParticipantsPermissions = () => {
@@ -16,9 +15,7 @@ export const useUpdateParticipantsPermissions = () => {
)
return Promise.all(promises)
} catch (error) {
reportError('permissions_api_failure', error, {
context: 'An error occurred while updating permissions :',
})
console.error('An error occurred while updating permissions :', error)
throw new Error('An error occurred while updating permissions.', {
cause: error,
})
@@ -25,17 +25,18 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user'
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
export const Conference = ({
roomId,
@@ -46,6 +47,7 @@ export const Conference = ({
mode?: 'join' | 'create'
initialRoomData?: ApiRoom
}) => {
const posthog = usePostHog()
const { data: apiConfig } = useConfig()
const { userChoices: userConfig } = usePersistentUserChoices() as {
@@ -55,8 +57,8 @@ export const Conference = ({
const { username } = useSnapshot(userStore)
useEffect(() => {
void captureMediaEvent('visit-room', { slug: roomId })
}, [roomId])
posthog.capture('visit-room', { slug: roomId })
}, [roomId, posthog])
const fetchKey = [keys.room, roomId]
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
@@ -168,6 +170,14 @@ export const Conference = ({
prepareConnection()
}, [room, apiConfig, isConnectionWarmedUp])
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}>({
error: null,
kind: null,
})
const isMobile = useIsMobile()
const hasAutoMutedRef = useRef(false)
@@ -225,11 +235,7 @@ export const Conference = ({
backgroundColor: 'primaryDark.50 !important',
})}
onError={(e) => {
const failure = MediaDeviceFailure.getFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return
reportError('livekit_room_error', e, {
path: 'connect_publish',
})
posthog.captureException(e)
}}
onConnected={async () => {
if (!apiConfig) return
@@ -247,8 +253,23 @@ export const Conference = ({
onDisconnected={(e) => {
const metadata = {
room_id: roomId,
pc_publisher: connectionObserverStore.publisher && {
...connectionObserverStore.publisher,
},
pc_subscriber: connectionObserverStore.subscriber && {
...connectionObserverStore.subscriber,
},
pc_publisher_changes_count:
connectionObserverStore.publisherChangesCount,
pc_subscriber_changes_count:
connectionObserverStore.subscriberChangesCount,
}
connectionObserverStore.publisher = null
connectionObserverStore.publisherChangesCount = 0
connectionObserverStore.subscriber = null
connectionObserverStore.subscriberChangesCount = 0
switch (e) {
case DisconnectReason.CLIENT_INITIATED:
navigateTo(
@@ -274,10 +295,18 @@ export const Conference = ({
return
}
}}
onMediaDeviceFailure={(e, kind) => {
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
setMediaDeviceError({ error: e, kind })
}
}}
>
<WatchMediaDeviceErrors />
<VideoConference />
{!isMobile && <InviteDialog mode={mode} />}
<MediaDeviceErrorAlert
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
/>
<PictureInPictureConference />
</LiveKitRoom>
</Screen>
@@ -45,7 +45,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData()
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
const roomUrl = getRouteUrl('room', roomData?.slug)
const telephony = useTelephony()
File diff suppressed because it is too large Load Diff
@@ -1,168 +0,0 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css'
import { VStack } from '@/styled-system/jsx'
import { H } from '@/primitives/H'
import { Field } from '@/primitives/Field'
import { Form, Text } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { useLoginHint } from '@/hooks/useLoginHint'
import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig'
import { saveUsername, userStore } from '@/stores/user'
import { fetchRoom } from '../api/fetchRoom'
import { ApiAccessLevel } from '../api/ApiRoom'
import { ApiLobbyStatus, type ApiRequestEntry } from '../api/requestEntry'
import { useLobby } from '../hooks/useLobby'
export const Lobby = ({
roomId,
enterRoom,
}: {
roomId: string
enterRoom: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { data: configData } = useConfig()
const { isLoggedIn, user } = useUser()
const { username } = useSnapshot(userStore)
// Room data strategy:
// 1. Initial fetch is performed to check access and get LiveKit configuration
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
// 3. State is manually updated via queryClient when a waiting participant is accepted
// 4. No automatic refetching or revalidation occurs during this period
const {
data: roomData,
error,
isError,
refetch: refetchRoom,
} = useQuery({
queryKey: [keys.room, roomId],
queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
retry: false,
enabled: false,
})
useEffect(() => {
if (isError && error?.statusCode == 404) {
// The room component will handle the room creation if the user is authenticated
enterRoom()
}
}, [isError, error, enterRoom])
const handleAccepted = (response: ApiRequestEntry) => {
queryClient.setQueryData([keys.room, roomId], {
...roomData,
livekit: response.livekit,
})
enterRoom()
}
const { status, startWaiting } = useLobby({
roomId,
username: username || user?.full_name || 'anonymous',
onAccepted: handleAccepted,
})
const { openLoginHint } = useLoginHint()
const handleSubmit = async () => {
const { data } = await refetchRoom()
if (!data?.livekit) {
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
if (data?.access_level == ApiAccessLevel.TRUSTED) {
openLoginHint()
}
startWaiting()
return
}
enterRoom()
}
switch (status) {
case ApiLobbyStatus.TIMEOUT:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('timeoutInvite.title')}
</H>
<Text as="p" variant="note">
{t('timeoutInvite.body')}
</Text>
</VStack>
)
case ApiLobbyStatus.DENIED:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('denied.title')}
</H>
<Text as="p" variant="note">
{t('denied.body')}
</Text>
</VStack>
)
case ApiLobbyStatus.WAITING:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('waiting.title')}
</H>
<Text
as="p"
variant="note"
className={css({ marginBottom: '1.5rem' })}
>
{t('waiting.body')}
</Text>
<Spinner />
</VStack>
)
default:
return (
<Form
onSubmit={handleSubmit}
submitLabel={t('joinLabel')}
submitButtonProps={{
fullWidth: true,
}}
>
<VStack marginBottom={1}>
<H lvl={1} margin="sm" centered>
{t('heading')}
</H>
{(!isLoggedIn ||
configData?.authenticated_users_can_edit_display_name) && (
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
id="input-name"
defaultValue={username || user?.full_name}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
)}
</VStack>
</Form>
)
}
}
@@ -1,114 +1,13 @@
import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
import { css } from '@/styled-system/css'
import { Button, Dialog, H, P } from '@/primitives'
import { Dialog, H } from '@/primitives'
import { RiEqualizer2Line } from '@remixicon/react'
import { useEffect, useMemo } from 'react'
import { useSnapshot } from 'valtio'
import {
closePermissionsDialog,
closeSystemPermissionsDialog,
permissionsStore,
} from '@/stores/permissions'
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
import { useTranslation } from 'react-i18next'
import { injectIconIntoTranslation } from '@/utils/translation'
import { isSafari } from '@/utils/livekit'
import { type OS, getOS } from '@/utils/os'
type StepsOs = 'macos' | 'windows' | 'android' | 'other'
const STEPS_OS: Record<OS, StepsOs> = {
macos: 'macos',
windows: 'windows',
android: 'android',
linux: 'other',
other: 'other',
}
const getSystemSettingsUrl = (os: OS, label: string): string | null => {
if (os === 'macos') {
if (label === 'camera')
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'
if (label === 'microphone')
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
return 'x-apple.systempreferences:com.apple.preference.security?Privacy'
}
if (os === 'windows') {
if (label === 'camera') return 'ms-settings:privacy-webcam'
if (label === 'microphone') return 'ms-settings:privacy-microphone'
return 'ms-settings:privacy'
}
return null
}
const SystemPermissions = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'systemPermissionDialog' })
const permissions = useSnapshot(permissionsStore)
const os = useMemo(() => getOS() || 'other', [])
const label = useMemo(() => {
if (permissions.microphoneSystemDenied && permissions.cameraSystemDenied) {
return 'cameraAndMicrophone'
}
if (permissions.cameraSystemDenied) return 'camera'
return 'microphone'
}, [permissions])
const isOpen = permissions.isSystemPermissionDialogOpen
// Auto-close once access works again (the user fixed the OS settings).
useEffect(() => {
if (
isOpen &&
!permissions.microphoneSystemDenied &&
!permissions.cameraSystemDenied
) {
closeSystemPermissionsDialog()
}
}, [isOpen, permissions])
const device = t(`device.${label}`)
const settingsUrl = getSystemSettingsUrl(os, label)
return (
<Dialog
isOpen={isOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${label}`)}
onClose={closeSystemPermissionsDialog}
>
<div
className={css({
maxWidth: '500px',
})}
>
<H lvl={1}>{t(`heading.${label}`)}</H>
<P>{t('intro', { device })}</P>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
{Array.from({ length: 2 }, (_, index) => (
<li key={index}>
{t(`steps.${STEPS_OS[os] || 'other'}.${index + 1}`, { device })}
</li>
))}
</ol>
{settingsUrl && (
<div className={css({ marginTop: '2rem' })}>
<Button
variant="primary"
size="sm"
onPress={() => {
window.open(settingsUrl, '_blank')
}}
>
{t('openSettings')}
</Button>
</div>
)}
</div>
</Dialog>
)
}
/**
* Singleton component - ensures permissions sync runs only once across the app.
@@ -166,74 +65,68 @@ export const Permissions = () => {
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
return (
<>
<SystemPermissions />
<Dialog
isOpen={permissions.isPermissionDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${permissionLabel}`, {
appTitle,
<Dialog
isOpen={permissions.isPermissionDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t(`heading.${permissionLabel}`, {
appTitle,
})}
onClose={closePermissionsDialog}
>
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
md: {
flexDirection: 'row',
},
})}
onClose={closePermissionsDialog}
>
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
width: '100%',
minHeight: '290px',
maxWidth: '290px',
})}
/>
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
md: {
flexDirection: 'row',
},
maxWidth: '400px',
})}
>
<img
src="/assets/camera_mic_permission.svg"
alt=""
className={css({
width: '100%',
minHeight: '290px',
maxWidth: '290px',
<H lvl={2}>
{t(`heading.${permissionLabel}`, {
appTitle,
})}
/>
<div
className={css({
maxWidth: '400px',
})}
>
<H lvl={2}>
{t(`heading.${permissionLabel}`, {
appTitle,
})}
</H>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
<li>
{isSafari() ? (
t('body.openMenu.safari', {
appDomain: window.origin.replace('https://', ''),
})
) : (
<>
{descriptionBeforeIcon}
<span
style={{
display: 'inline-block',
verticalAlign: 'middle',
}}
>
<RiEqualizer2Line />
</span>
{descriptionAfterIcon}
</>
)}
</li>
<li>{t(`body.details.${permissionLabel}`)}</li>
</ol>
</div>
</H>
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
<li>
{isSafari() ? (
t('body.openMenu.safari', {
appDomain: window.origin.replace('https://', ''),
})
) : (
<>
{descriptionBeforeIcon}
<span
style={{ display: 'inline-block', verticalAlign: 'middle' }}
>
<RiEqualizer2Line />
</span>
{descriptionAfterIcon}
</>
)}
</li>
<li>{t(`body.details.${permissionLabel}`)}</li>
</ol>
</div>
</Dialog>
</>
</div>
</Dialog>
)
}
@@ -1,11 +1,13 @@
import { Button, H, Text, TextArea } from '@/primitives'
import { Button, H, Input, 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 { captureEvent } from '@/features/analytics/telemetry'
import type { CandidateInfo } from '@/stores/connectionObserver'
const Card = styled('div', {
base: {
@@ -70,9 +72,11 @@ const labelRecipe = cva({
})
const OpenFeedback = ({
posthog,
onNext,
metadata,
}: {
posthog: PostHog
onNext: () => void
metadata?: Record<string, unknown>
}) => {
@@ -86,7 +90,7 @@ const OpenFeedback = ({
const onSubmit = () => {
try {
captureEvent('open-feedback', {
posthog.capture('open-feedback', {
feedback,
...metadata,
})
@@ -137,10 +141,12 @@ const OpenFeedback = ({
}
const RateQuality = ({
posthog,
onNext,
metadata,
maxRating = 5,
}: {
posthog: PostHog
onNext: () => void
metadata?: Record<string, unknown>
maxRating?: number
@@ -154,7 +160,7 @@ const RateQuality = ({
const onSubmit = () => {
try {
captureEvent('quality-rating', {
posthog.capture('quality-rating', {
rating: selectedRating,
...metadata,
})
@@ -237,8 +243,73 @@ 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
pc_subscriber?: CandidateInfo
pc_publisher_changes_count?: number
pc_subscriber_changes_count?: number
}
export const Rating = ({
@@ -247,6 +318,12 @@ 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(), [])
@@ -262,14 +339,37 @@ export const Rating = ({
if (!isAnalyticsEnabled) return
if (step == 0) {
return <RateQuality onNext={() => setStep(step + 1)} metadata={metadata} />
return (
<RateQuality
posthog={posthog}
onNext={() => setStep(step + 1)}
metadata={metadata}
/>
)
}
if (step == 1) {
return <OpenFeedback onNext={() => setStep(step + 1)} metadata={metadata} />
return (
<OpenFeedback
posthog={posthog}
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,123 +0,0 @@
import { useEffect, useRef } from 'react'
import { useSnapshot } from 'valtio'
import { createAudioAnalyser, LocalAudioTrack } from 'livekit-client'
import { useLocalParticipant } from '@livekit/components-react'
import { reportMicSample, silentMicStore } from '@/stores/silentMic'
import { captureMediaEvent } from '@/features/analytics/telemetry'
import { useIsTrackMuted } from '../livekit/hooks/useIsTrackMuted'
// A live microphone always has a noise floor; only a signal pinned to
// zero counts as silent (no audio data flowing at all).
const SILENT_VOLUME_EPSILON = 0.0001
const TICK_MS = 1_000
type SilentMicContext = 'join' | 'room'
const ActiveDetector = ({
track,
context,
}: {
track: LocalAudioTrack
context: SilentMicContext
}) => {
const isMuted = useIsTrackMuted(track)
// The interval reads through refs so state updates never re-arm the
// timer or the analyser.
const mutedRef = useRef(isMuted)
mutedRef.current = isMuted
const contextRef = useRef(context)
contextRef.current = context
useEffect(() => {
let audioAnalyser: ReturnType<typeof createAudioAnalyser>
try {
audioAnalyser = createAudioAnalyser(track, {
fftSize: 256,
smoothingTimeConstant: 0.7,
})
} catch {
void captureMediaEvent('silent-mic-analyser-unavailable', {
context: contextRef.current,
})
return
}
const { analyser, calculateVolume, cleanup } = audioAnalyser
const tick = () => {
// Zero volume is only evidence of silence when audio data is
// actually flowing. Skip the sample when:
// - the mic is intentionally muted;
// - the tab is backgrounded (suspended AudioContext reads as zero);
// - the AudioContext is not running yet — Chrome keeps it
// 'suspended' until a user gesture on pages loaded without
// activation, and a suspended analyser reports zeros for a
// perfectly healthy microphone.
if (
mutedRef.current ||
document.visibilityState !== 'visible' ||
analyser.context.state !== 'running'
) {
return
}
const result = reportMicSample({
trackId: track.mediaStreamTrack?.id,
silent: calculateVolume() <= SILENT_VOLUME_EPSILON,
deltaMs: TICK_MS,
})
if (result === 'silent-detected') {
void captureMediaEvent('silent-mic-detected', {
context: contextRef.current,
media_stream_track_muted: track.mediaStreamTrack?.muted ?? null,
})
} else if (result === 'recovered') {
void captureMediaEvent('silent-mic-recovered', {
context: contextRef.current,
})
}
}
const interval = window.setInterval(tick, TICK_MS)
return () => {
window.clearInterval(interval)
void cleanup()
}
}, [track])
return null
}
/**
* One-shot silent-mic check (see stores/silentMic.ts). Renders nothing;
* mounts the volume watcher only while the check is still undecided so
* the analyser goes away as soon as the outcome is known.
*/
export const SilentMicDetector = ({
track,
context,
}: {
track?: LocalAudioTrack
context: SilentMicContext
}) => {
const { status } = useSnapshot(silentMicStore)
if ((status !== 'watching' && status !== 'silent') || !track) {
return null
}
return (
<ActiveDetector
key={track.mediaStreamTrack?.id}
track={track}
context={context}
/>
)
}
/** Room-side variant: watches the published local microphone track. */
export const RoomSilentMicDetector = () => {
const { microphoneTrack } = useLocalParticipant()
const track =
microphoneTrack?.track instanceof LocalAudioTrack
? microphoneTrack.track
: undefined
return <SilentMicDetector track={track} context="room" />
}
@@ -1,64 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { css } from '@/styled-system/css'
import { Button, Dialog, H, P } from '@/primitives'
import {
closeSilentMicDialog,
discardSilentMicDetection,
silentMicStore,
} from '@/stores/silentMic'
/**
* Opened from the "!" badge on the microphone toggle when the silent-mic
* check tripped (see stores/silentMic.ts). Explains the likely causes
* and lets the user opt out of the detection for good.
*/
export const SilentMicDialog = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'silentMic.dialog' })
const { isDialogOpen } = useSnapshot(silentMicStore)
return (
<Dialog
isOpen={isDialogOpen}
role="dialog"
type="flex"
title=""
aria-label={t('title')}
onClose={closeSilentMicDialog}
>
<div
className={css({
maxWidth: '500px',
})}
>
<H lvl={1}>{t('title')}</H>
<P>{t('intro')}</P>
<ul className={css({ listStyle: 'disc', paddingLeft: '24px' })}>
<li>{t('causes.system')}</li>
<li>{t('causes.hardware')}</li>
<li>{t('causes.wrongDevice')}</li>
</ul>
<P>{t('hint')}</P>
<div
className={css({
marginTop: '1.5rem',
display: 'flex',
gap: '1rem',
flexWrap: 'wrap',
})}
>
<Button variant="primary" size="sm" onPress={closeSilentMicDialog}>
{t('close')}
</Button>
<Button
variant="tertiary"
size="sm"
onPress={discardSilentMicDetection}
>
{t('discard')}
</Button>
</div>
</div>
</Dialog>
)
}
@@ -1,11 +0,0 @@
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { useWatchMediaDeviceErrors } from '../livekit/hooks/useWatchMediaDeviceErrors'
/**
* Single place responsible for the room's media device errors mounts the
* watcher and renders the resulting user-facing alert.
*/
export const WatchMediaDeviceErrors = () => {
const { error, kind, clear } = useWatchMediaDeviceErrors()
return <MediaDeviceErrorAlert error={error} kind={kind} onClose={clear} />
}
@@ -1,19 +0,0 @@
import { useEffect } from 'react'
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
export function useWatchDeviceAvailability() {
useEffect(() => {
if (!navigator.mediaDevices) return
syncDeviceAvailability()
navigator.mediaDevices.addEventListener(
'devicechange',
syncDeviceAvailability
)
return () => {
navigator.mediaDevices.removeEventListener(
'devicechange',
syncDeviceAvailability
)
}
}, [])
}
@@ -1,36 +1,160 @@
import { useEffect } from 'react'
import { syncPermissions } from '@/stores/permissions'
import { permissionsStore } from '@/stores/permissions'
import { isSafari } from '@/utils/livekit'
const POLLING_TIME = 500
export const useWatchPermissions = () => {
useEffect(() => {
const sync = () => void syncPermissions()
sync()
let cleanup: (() => void) | undefined
let intervalId: ReturnType<typeof setTimeout> | undefined
let isCancelled = false
navigator.mediaDevices?.addEventListener?.('devicechange', sync)
window.addEventListener('focus', sync)
const checkPermissions = async () => {
try {
if (!navigator.permissions) {
if (!isCancelled) {
permissionsStore.cameraPermission = 'unavailable'
permissionsStore.microphonePermission = 'unavailable'
}
return
}
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.
})
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
}
}
}
checkPermissions()
return () => {
cancelled = true
navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
window.removeEventListener('focus', sync)
statuses.forEach((s) => s.removeEventListener('change', sync))
isCancelled = true
cleanup?.()
}
}, [])
}
@@ -13,7 +13,6 @@ 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' })
@@ -50,7 +49,12 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
const { toggleMuting, isMutingEnabled } = usePermissionsManager()
const {
toggleMuting,
isMutingEnabled,
isAuthenticatedCanRecordEnabled,
toggleAuthenticatedCanRecord,
} = usePermissionsManager()
return (
<Div
@@ -155,6 +159,17 @@ export const Admin = () => {
fullWidth: true,
}}
/>
<Field
type="switch"
label={'Authenticated can record'}
description={'Authenticated can record'}
isSelected={isAuthenticatedCanRecordEnabled}
onChange={toggleAuthenticatedCanRecord}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</div>
</div>
<div
@@ -206,7 +221,7 @@ export const Admin = () => {
patchRoom({
roomId,
room: { access_level: value as ApiAccessLevel },
}).catch((e) => reportError('generic_failure', e))
}).catch((e) => console.error(e))
}
items={[
{
@@ -9,16 +9,28 @@ import { useSnapshot } from 'valtio'
import { DisconnectReason, RoomEvent } from 'livekit-client'
import { userPreferencesStore } from '@/stores/userPreferences'
import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
import { connectionObserverStore } from '@/stores/connectionObserver'
import posthog from 'posthog-js'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { isMobileBrowser } from '@livekit/components-core'
import { FeatureFlags } from '@/features/analytics/enums'
const CANDIDATE_POLL_INTERVAL_MS = 5000
export const ConnectionObserver = () => {
const room = useRoomContext()
const connectionStartTimeRef = useRef<number | null>(null)
const { data } = useConfig()
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
const isMobile = isMobileBrowser()
const isAdvancedConnectionObserverEnabled =
!isMobile && isAnalyticsEnabled && featureEnabled
const userPreferencesSnap = useSnapshot(userPreferencesStore)
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
@@ -68,6 +80,100 @@ export const ConnectionObserver = () => {
userPreferencesSnap.is_idle_disconnect_modal_enabled,
])
useEffect(() => {
if (!isAdvancedConnectionObserverEnabled) return
if (!room) return
let interval: ReturnType<typeof setInterval> | null = null
const pollCandidate = async (
label: 'publisher' | 'subscriber',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pc?: any
) => {
if (!pc) return
let stats: RTCStatsReport
try {
stats = await pc.getStats()
} catch {
return
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
stats.forEach((report: any) => {
if (
report.type === 'candidate-pair' &&
report.state === 'succeeded' &&
report.nominated
) {
const remoteCandidate = stats.get(report.remoteCandidateId)
if (!remoteCandidate) return
const next = {
type: remoteCandidate.candidateType,
address: remoteCandidate.address,
protocol: remoteCandidate.protocol,
}
const current = connectionObserverStore[label]
const hasChanged =
current?.type !== next.type ||
current?.address !== next.address ||
current?.protocol !== next.protocol
if (hasChanged) {
connectionObserverStore[label] = next
const key = `${label}ChangesCount` as const
connectionObserverStore[key] =
(connectionObserverStore[key] || 0) + 1
}
}
})
}
const poll = async () => {
const publisher = room.engine?.pcManager?.publisher
const subscriber = room.engine?.pcManager?.subscriber
await Promise.all([
pollCandidate('publisher', publisher),
pollCandidate('subscriber', subscriber),
])
}
const startPolling = async () => {
if (interval) return // prevent duplicates
// Initial snapshot
await poll()
interval = setInterval(poll, CANDIDATE_POLL_INTERVAL_MS)
}
const stopPolling = () => {
if (!interval) return
clearInterval(interval)
interval = null
}
room.on(RoomEvent.Connected, startPolling)
room.on(RoomEvent.Reconnected, startPolling)
room.on(RoomEvent.Reconnecting, stopPolling)
room.on(RoomEvent.Disconnected, stopPolling)
return () => {
stopPolling()
room.off(RoomEvent.Connected, startPolling)
room.off(RoomEvent.Reconnected, startPolling)
room.off(RoomEvent.Reconnecting, stopPolling)
room.off(RoomEvent.Disconnected, stopPolling)
}
}, [room, isAdvancedConnectionObserverEnabled])
useEffect(() => {
if (!isAnalyticsEnabled) return
@@ -76,23 +182,23 @@ export const ConnectionObserver = () => {
// total session duration from first connect to final disconnect.
if (connectionStartTimeRef.current != null) return
connectionStartTimeRef.current = Date.now()
void captureMediaEvent('connection-event', {})
posthog.capture('connection-event')
}
const handleReconnect = () => {
captureEvent('reconnect-event')
posthog.capture('reconnect-event')
}
const handleReconnected = () => {
captureEvent('reconnected-event')
posthog.capture('reconnected-event')
}
const handleSignalingConnect = () => {
captureEvent('signaling-connect-event')
posthog.capture('signaling-connect-event')
}
const handleSignalingReconnect = () => {
captureEvent('signaling-reconnect-event')
posthog.capture('signaling-reconnect-event')
}
const handleDisconnect = (
@@ -100,7 +206,7 @@ export const ConnectionObserver = () => {
) => {
const connectionEndTime = Date.now()
captureEvent('disconnect-event', {
posthog.capture('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 = data?.slug ? getRouteUrl('room', data.slug) : ''
const roomUrl = getRouteUrl('room', data?.slug)
const telephony = useTelephony()
@@ -1,13 +1,6 @@
import { A, Button, Dialog, P } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { getOS, type OS } from '@/utils/os'
const SCREEN_CAPTURE_SETTINGS_LINKS: Partial<Record<OS, string>> = {
macos:
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
windows: 'ms-settings:privacy-graphicscaptureprogrammatic',
}
// todo - refactor it into a generic system
export const ScreenShareErrorModal = ({
@@ -18,8 +11,7 @@ export const ScreenShareErrorModal = ({
onClose: () => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
const os = getOS()
const settingsHref = SCREEN_CAPTURE_SETTINGS_LINKS[os]
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
return (
<Dialog
@@ -34,16 +26,15 @@ export const ScreenShareErrorModal = ({
<>
<P>
{t('message')}{' '}
{settingsHref && (
{isMac && (
<>
{t('settingsInstructions')}{' '}
{t('macInstructions')}{' '}
<A
href={settingsHref}
target="_blank"
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
color="primary"
aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
>
{t(`settingsLabel.${os}`)}
{t('macSystemPreferences')}
</A>
.{' '}
</>
@@ -1,20 +0,0 @@
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,4 +1,5 @@
import type { ProcessorOptions, Track } from 'livekit-client'
import posthog from 'posthog-js'
import {
FilesetResolver,
ImageSegmenter,
@@ -17,7 +18,6 @@ import {
type ProcessorType,
MEDIAPIPE_PATH_WASM,
} from '.'
import { captureEvent } from '@/features/analytics/telemetry.ts'
const PROCESSING_WIDTH = 256
const PROCESSING_HEIGHT = 144
@@ -44,7 +44,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
videoElement?: HTMLVideoElement
videoElementLoaded?: boolean
// Canvas containing the video processing result, of which we extract as stream.
// Canvas containg the video processing result, of which we extract as stream.
outputCanvas?: HTMLCanvasElement
outputCanvasCtx?: CanvasRenderingContext2D
@@ -55,7 +55,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
segmentationMaskCanvas?: HTMLCanvasElement
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
// Mask containing the inference result.
// Mask containg the inference result.
segmentationMask?: ImageData
// The resized image of the video source.
@@ -100,7 +100,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
await this.initSegmenter()
this._initWorker()
captureEvent('firefox-blurring-init', {})
posthog.capture('firefox-blurring-init')
}
_initVirtualBackgroundImage() {
@@ -1,4 +1,5 @@
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
import posthog from 'posthog-js'
import {
FilesetResolver,
FaceLandmarker,
@@ -15,7 +16,6 @@ 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()
captureEvent('face-landmarks-init', {})
posthog.capture('face-landmarks-init')
}
_initWorker() {
@@ -1,7 +1,4 @@
import {
ProcessorWrapper,
supportsBackgroundProcessors,
} from '@livekit/track-processors'
import { ProcessorWrapper } from '@livekit/track-processors'
import type { Track, TrackProcessor } from 'livekit-client'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
@@ -13,7 +10,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/${__MEDIAPIPE_VERSION__}`
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
export enum ProcessorType {
BLUR = 'blur',
@@ -37,9 +34,7 @@ export class BackgroundProcessorFactory {
}
static isSupported() {
return (
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
)
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
}
static getProcessor(
@@ -50,7 +45,7 @@ export class BackgroundProcessorFactory {
if (!isBlur && !isVirtual) return undefined
if (supportsBackgroundProcessors()) {
if (ProcessorWrapper.isSupported) {
return new UnifiedBackgroundTrackProcessor(config)
}
@@ -4,7 +4,6 @@ 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',
@@ -104,11 +103,7 @@ export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
setActiveMediaDevice(device.deviceId)
setFacingMode(target)
} else {
reportError(
'device_switch_failure',
new Error('Cannot get user device with facingMode ' + target),
{ path: 'switch_device', kind: 'videoinput', facing_mode: target }
)
console.error('Cannot get user device with facingMode ' + target)
}
}
return (
@@ -1,12 +1,8 @@
import { useTranslation } from 'react-i18next'
import {
useLocalParticipant,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Button, Popover } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react'
import { LocalAudioTrack, Track } from 'livekit-client'
import { Track } from 'livekit-client'
import { ToggleDevice } from './ToggleDevice'
import { css } from '@/styled-system/css'
@@ -55,12 +51,6 @@ 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}`)
@@ -121,7 +111,6 @@ export const AudioDevicesControl = ({
context="room"
kind={kind}
id={audioDeviceId}
track={localAudioTrack}
onSubmit={saveAudioInputDeviceId}
/>
</div>
@@ -1,132 +0,0 @@
import { LocalAudioTrack } from 'livekit-client'
import { useTrackVolume } from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Text } from '@/primitives'
import { useIsTrackMuted } from '../../../hooks/useIsTrackMuted'
const StyledContainer = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem 0.25rem',
marginTop: '0.5rem',
borderTop: '1px solid',
minHeight: '2.5rem',
},
variants: {
theme: {
light: {
borderColor: 'gray.200',
color: 'greyscale.600',
},
dark: {
borderColor: 'primaryDark.300',
color: 'rgba(255 255 255 / 0.7)',
},
},
},
})
const StyledGaugeContainer = styled('div', {
base: {
flexGrow: 1,
height: '0.375rem',
borderRadius: '0.1875rem',
overflow: 'hidden',
},
variants: {
theme: {
light: {
backgroundColor: 'greyscale.250',
},
dark: {
backgroundColor: 'rgba(255 255 255 / 0.25)',
},
},
},
})
const StyledGauge = styled('div', {
base: {
width: '100%',
height: '100%',
borderRadius: 'inherit',
transformOrigin: 'left center',
transform: 'scaleX(0)',
transition: 'transform 0.06s linear',
},
variants: {
theme: {
light: {
backgroundColor: 'primary.500',
},
dark: {
backgroundColor: 'primaryDark.800',
},
},
},
})
type Theme = 'light' | 'dark'
type AudioLevelGaugeProps = {
track?: LocalAudioTrack
variant?: Theme
}
const LevelBar = ({
track,
theme,
}: {
track: LocalAudioTrack
theme: Theme
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const volume = useTrackVolume(track, {
fftSize: 256,
smoothingTimeConstant: 0.7,
})
const level = Math.min(1, volume)
return (
<>
<RiMicLine size={18} aria-hidden="true" />
<StyledGaugeContainer
theme={theme}
role="img"
aria-label={t('audioinput.level')}
>
<StyledGauge theme={theme} style={{ transform: `scaleX(${level})` }} />
</StyledGaugeContainer>
</>
)
}
export const AudioLevelGauge = ({
track,
variant = 'light',
}: AudioLevelGaugeProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const isMuted = useIsTrackMuted(track)
const showMutedHint = !track || isMuted
return (
<StyledContainer theme={variant}>
{showMutedHint ? (
<>
<RiMicOffLine size={18} aria-hidden="true" />
<Text variant="bodyXsMedium">{t('audioinput.muteTest')}</Text>
</>
) : (
<LevelBar
key={track.mediaStreamTrack?.id}
track={track}
theme={variant}
/>
)}
</StyledContainer>
)
}
@@ -1,134 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiVolumeUpLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
// Speaker test in the audiooutput menu footer (Meet-style UX). Outputs have
// no track: the test plays a bundled file through the selected sink, and
// following `sinkId` mid-playback re-routes it live. No permission involved.
type Theme = 'light' | 'dark'
const BUTTON_VARIANT = {
light: 'quaternaryText',
dark: 'primaryTextDark',
} as const
const StyledContainer = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
paddingTop: '0.5rem',
marginTop: '0.5rem',
borderTop: '1px solid',
},
variants: {
theme: {
light: {
borderColor: 'gray.200',
},
dark: {
borderColor: 'primaryDark.300',
},
},
},
})
const StyledButtonContent = styled('span', {
base: {
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 'full',
paddingX: '1.625rem',
'& > svg': {
position: 'absolute',
left: 0,
},
},
})
type OutputSoundTesterProps = {
/** The device the test should play through (the select's current key). */
sinkId?: string
variant?: Theme
}
export const OutputSoundTester = ({
sinkId,
variant = 'light',
}: OutputSoundTesterProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const latestSinkIdRef = useRef(sinkId)
latestSinkIdRef.current = sinkId
const stopPlayback = useCallback(() => {
const audio = audioRef.current
if (audio) {
audio.pause()
audio.currentTime = 0
}
setIsPlaying(false)
}, [])
useEffect(() => {
if (!sinkId || !canTestAudioOutput()) return
audioRef.current?.setSinkId(sinkId).catch(() => {
// Re-routing failed (stale or unplugged device): stop the test rather
// than keep playing through the previous sink.
if (latestSinkIdRef.current === sinkId) {
stopPlayback()
}
})
}, [sinkId, stopPlayback])
useEffect(() => {
const audio = audioRef.current
return () => audio?.pause()
}, [])
return (
<StyledContainer theme={variant}>
<Button
variant={BUTTON_VARIANT[variant]}
size="sm"
fullWidth
isDisabled={isPlaying}
onPress={async () => {
const audio = audioRef.current
if (!audio) return
try {
// Confirm routing before starting: a no-op when already routed,
// but rejects on a stale device id, so the test never plays
// through the wrong sink.
if (sinkId && canTestAudioOutput()) {
await audio.setSinkId(sinkId)
}
await audio.play()
setIsPlaying(true)
} catch {
stopPlayback()
}
}}
>
<StyledButtonContent>
<RiVolumeUpLine size={18} aria-hidden />
{isPlaying ? t('audiooutput.testing') : t('audiooutput.test')}
</StyledButtonContent>
</Button>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src="/sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)}
/>
</StyledContainer>
)
}
@@ -4,17 +4,8 @@ import { openPermissionsDialog } from '@/stores/permissions'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
type PermissionNeededButtonProps = {
tooltip?: string
onPress?: () => void
}
export const PermissionNeededButton = ({
tooltip,
onPress,
}: PermissionNeededButtonProps) => {
export const PermissionNeededButton = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
const label = tooltip ?? t('tooltip')
return (
<div
className={css({
@@ -26,9 +17,9 @@ export const PermissionNeededButton = ({
})}
>
<Button
aria-label={tooltip ? label : t('ariaLabel')}
tooltip={label}
onPress={onPress ?? (() => openPermissionsDialog())}
aria-label={t('ariaLabel')}
tooltip={t('tooltip')}
onPress={() => openPermissionsDialog()}
variant="permission"
>
<div
@@ -4,12 +4,7 @@ import { useEffect, useMemo } from 'react'
import { Select, SelectProps } from '@/primitives/Select'
import type { Placement } from '@react-types/overlays'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
import type { LocalAudioTrack } from 'livekit-client'
import { AudioLevelGauge } from './AudioLevelGauge'
import { OutputSoundTester } from './OutputSoundTester'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
type DeviceItems = Array<{ value: string; label: string }>
@@ -23,7 +18,6 @@ type SelectDeviceProps = {
onSubmit?: (id: string) => void
kind: MediaDeviceKind
context?: 'join' | 'room'
track?: LocalAudioTrack
}
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
@@ -34,7 +28,6 @@ const SelectDevicePermissions = <T extends string | number>({
kind,
onSubmit,
iconComponent,
track,
...props
}: SelectDevicePermissionsProps<T>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -81,16 +74,6 @@ 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}
/>
)
@@ -101,7 +84,6 @@ export const SelectDevice = ({
onSubmit,
kind,
context = 'join',
track,
}: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
@@ -114,25 +96,6 @@ export const SelectDevice = ({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind)
if (deviceMissing) {
return (
<Select
aria-label={t(`NotFound.title.${kind}`, {
keyPrefix: 'mediaErrorDialog',
})}
label=""
isDisabled={true}
items={[]}
placeholder={t(`NotFound.title.${kind}`, {
keyPrefix: 'mediaErrorDialog',
})}
iconComponent={deviceIcons.select}
{...contextProps}
/>
)
}
if (cannotUseDevice) {
return (
@@ -153,7 +116,6 @@ 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, useRef, useState } from 'react'
import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next'
import { PermissionNeededButton } from './PermissionNeededButton'
@@ -12,16 +12,10 @@ import {
useMaybeRoomContext,
useRoomContext,
} from '@livekit/components-react'
import { MediaDeviceFailure } from 'livekit-client'
import { MediaDeviceErrorAlert } from '@/features/rooms/components/MediaDeviceErrorAlert'
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions'
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
import { useSnapshot } from 'valtio'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import type {
@@ -96,45 +90,9 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind)
const { status: silentMicStatus } = useSnapshot(silentMicStore)
const silentMicWarning =
kind === 'audioinput' &&
silentMicStatus === 'silent' &&
!cannotUseDevice &&
!deviceMissing
const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce()
const isRequestingPermission = useRef(false)
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
const onPress = async () => {
if (!enabled && deviceMissing) {
setShowDeviceNotFound(true)
return
}
if (!cannotUseDevice) {
toggle()
return
}
if (isRequestingPermission.current) return
isRequestingPermission.current = true
try {
const granted = await requestDevicePermission(
kind,
context === 'join' ? 'join_preview' : 'room'
)
if (granted) {
toggle()
} else {
openPermissionsDialog(kind)
}
} finally {
isRequestingPermission.current = false
}
}
useRegisterKeyboardShortcut({
id: deviceShortcut?.id,
handler: async () => {
@@ -181,20 +139,7 @@ export const ToggleDevice = <T extends ToggleSource>({
return (
<div style={{ position: 'relative' }}>
{(cannotUseDevice || deviceMissing) && (
<PermissionNeededButton
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
onPress={
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
}
/>
)}
{silentMicWarning && (
<PermissionNeededButton
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
onPress={openSilentMicDialog}
/>
)}
{cannotUseDevice && <PermissionNeededButton />}
<ToggleButton
isSelected={!enabled}
isDisabled={isDisabled}
@@ -202,25 +147,23 @@ export const ToggleDevice = <T extends ToggleSource>({
isDisabled || cannotUseDevice || !enabled ? errorVariant : variant
}
shySelected
onPress={onPress}
onPress={() => {
if (cannotUseDevice) {
openPermissionsDialog(kind)
}
toggle()
}}
aria-label={toggleLabel}
tooltip={
deviceMissing
? t(`deviceNotFound.${kind}`)
: cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
{...computedToggleButtonProps}
{...overrideToggleButtonProps}
>
<Icon />
</ToggleButton>
<MediaDeviceErrorAlert
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
kind={kind}
onClose={() => setShowDeviceNotFound(false)}
/>
</div>
)
}
@@ -3,7 +3,6 @@ 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' })
@@ -16,11 +15,11 @@ export const LeaveButton = () => {
tooltip={t('leave')}
aria-label={t('leave')}
onPress={() => {
room.disconnect(true).catch((e) =>
reportError('disconnect_failure', e, {
context: 'An error occurred while disconnecting:',
})
)
room
.disconnect(true)
.catch((e) =>
console.error('An error occurred while disconnecting:', e)
)
}}
data-attr="controls-leave"
>
@@ -33,7 +33,6 @@ 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,
@@ -239,9 +238,7 @@ export const EffectsConfiguration = ({
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
} catch (error) {
reportError('effects_processor_failure', error, {
context: 'Error applying effect:',
})
console.error('Error applying effect:', error)
} finally {
// Without setTimeout the DOM is not refreshing when updating the options.
setTimeout(() => setProcessorPending(false))
@@ -5,7 +5,6 @@ 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
@@ -56,9 +55,7 @@ export const FunnyEffects = ({
await videoTrack.setProcessor(newProcessor)
}
} catch (e) {
reportError('effects_processor_failure', e, {
context: 'could not update processor',
})
console.error('could not update processor', e)
} finally {
onPending(false)
}
@@ -9,8 +9,6 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
microphoneSystemDenied,
cameraSystemDenied,
} = useSnapshot(permissionsStore)
return useMemo(() => {
@@ -19,11 +17,9 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
switch (kind) {
case 'audioinput':
case 'audiooutput': // audiooutput uses microphone permissions
return (
isMicrophoneDenied || isMicrophonePrompted || microphoneSystemDenied
)
return isMicrophoneDenied || isMicrophonePrompted
case 'videoinput':
return isCameraDenied || isCameraPrompted || cameraSystemDenied
return isCameraDenied || isCameraPrompted
default:
return false
@@ -35,7 +31,5 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
isMicrophonePrompted,
isCameraDenied,
isCameraPrompted,
microphoneSystemDenied,
cameraSystemDenied,
])
}
@@ -4,7 +4,6 @@ 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
@@ -59,9 +58,7 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
await navigator.clipboard.writeText(content)
setIsCopied(true)
} catch (error) {
reportError('clipboard_failure', error, {
context: 'copy_room_content',
})
console.error(error)
}
}
@@ -70,9 +67,7 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
await navigator.clipboard.writeText(roomUrl)
setIsRoomUrlCopied(true)
} catch (error) {
reportError('clipboard_failure', error, {
context: 'copy_room_url',
})
console.error(error)
}
}
@@ -1,27 +0,0 @@
import { useSnapshot } from 'valtio'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
import { permissionsStore } from '@/stores/permissions'
/**
* enumerateDevices() may hide OS/app-blocked devices on Firefox Android.
* Only report "missing" when no permission block explains the absence,
* so the permission UI takes precedence.
*/
export const useDeviceMissing = (kind: MediaDeviceKind): boolean => {
const { hasCamera, hasMicrophone } = useSnapshot(deviceAvailabilityStore)
const {
cameraSystemDenied,
microphoneSystemDenied,
isCameraDenied,
isMicrophoneDenied,
} = useSnapshot(permissionsStore)
switch (kind) {
case 'videoinput':
return !hasCamera && !cameraSystemDenied && !isCameraDenied
case 'audioinput':
return !hasMicrophone && !microphoneSystemDenied && !isMicrophoneDenied
default:
return false
}
}
@@ -4,7 +4,6 @@
import { useMemo, useState } from 'react'
import { type TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { reportError } from '@/features/analytics/telemetry'
export function useFullScreen({
trackRef,
@@ -57,9 +56,7 @@ export function useFullScreen({
await docEl.msRequestFullscreen()
}
} catch (error) {
reportError('fullscreen_failure', error, {
context: 'Error entering fullscreen:',
})
console.error('Error entering fullscreen:', error)
}
}
@@ -73,9 +70,7 @@ export function useFullScreen({
await document.msExitFullscreen()
}
} catch (error) {
reportError('fullscreen_failure', error, {
context: 'Error exiting fullscreen:',
})
console.error('Error exiting fullscreen:', error)
}
}
@@ -1,24 +0,0 @@
import { type LocalAudioTrack, TrackEvent } from 'livekit-client'
import { useEffect, useState } from 'react'
export const useIsTrackMuted = (track?: LocalAudioTrack) => {
const [isMuted, setIsMuted] = useState(() => track?.isMuted ?? true)
useEffect(() => {
if (!track) {
setIsMuted(true)
return
}
setIsMuted(track.isMuted)
const onMuted = () => setIsMuted(true)
const onUnmuted = () => setIsMuted(false)
track.on(TrackEvent.Muted, onMuted)
track.on(TrackEvent.Unmuted, onUnmuted)
return () => {
track.off(TrackEvent.Muted, onMuted)
track.off(TrackEvent.Unmuted, onUnmuted)
}
}, [track])
return isMuted
}
@@ -1,336 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useSnapshot } from 'valtio'
import {
createLocalAudioTrack,
createLocalVideoTrack,
type LocalAudioTrack,
type LocalVideoTrack,
MediaDeviceFailure,
TrackEvent,
} from 'livekit-client'
import { BackgroundProcessorFactory } from '../components/blur'
import {
classifyPermissionError,
isLikelySystemNotFound,
isSystemPermissionError,
noteGumSuccess,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
type PermissionKind,
} from '@/stores/permissions'
import { getOS } from '@/utils/os'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import {
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveVideoInputDeviceId,
saveVideoInputEnabled,
userChoicesStore,
} from '@/stores/userChoices'
import { useSyncTrackDeviceId } from './useSyncTrackDeviceId'
const VOICE_AUDIO_CONSTRAINTS = {
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
voiceIsolation: false,
sampleRate: 48000,
channelCount: 1,
sampleSize: 16,
} as const
const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
audioinput: 'microphone',
videoinput: 'camera',
}
type MediaPath = 'join_preview' | 'room'
const onMediaPermissionError = (
e: Error,
kind?: PermissionKind,
path: MediaPath = 'join_preview'
) => {
if (
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
) {
void classifyPermissionError(e, kind).then((scope) => {
if (scope === 'system') {
noteSystemPermissionDenied(kind)
} else {
notePermissionDeniedFromGum(kind)
}
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: scope,
os: getOS(),
})
})
return
}
if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) {
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
// settings, missing Android app permissions).
void isLikelySystemNotFound(e, kind).then((system) => {
if (system) {
noteSystemPermissionDenied(kind)
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: 'system',
os: getOS(),
})
return
}
captureMediaEvent('device-not-found', { path, kind })
})
return
}
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
reportError(
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
e,
{ path, kind }
)
}
// Module-level: effect dependencies, must be referentially stable.
const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false)
const stopAll = (stream: MediaStream) =>
stream.getTracks().forEach((track) => track.stop())
export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput',
path: MediaPath = 'join_preview'
): Promise<boolean> => {
try {
const track =
kind === 'audioinput'
? await createLocalAudioTrack()
: await createLocalVideoTrack()
track.stop()
noteGumSuccess(PERMISSION_KIND[kind])
return true
} catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
return false
}
}
type WarmupState = {
audioReady: boolean
videoReady: boolean
}
/**
* Requests camera and microphone once on mount (one combined call at
* most one browser dialog) and releases them immediately. Readiness is
* per kind: track acquisition must wait for its own kind to be settled
* to avoid a second dialog, but a microphone that stalls or fails (e.g.
* OS-level block on Firefox) must not hold the camera hostage that is
* how LiveKit behaves in-room (independent per-kind acquisitions).
*/
function useWarmupPermissions(): WarmupState {
const [state, setState] = useState<WarmupState>({
audioReady: false,
videoReady: false,
})
const started = useRef(false)
useEffect(() => {
if (started.current) {
return
}
started.current = true
const bothReady = () => setState({ audioReady: true, videoReady: true })
const warmup = async () => {
try {
stopAll(
await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
})
)
noteGumSuccess()
bothReady()
} catch (error) {
if (
MediaDeviceFailure.getFailure(error as Error) ===
MediaDeviceFailure.PermissionDenied &&
!isSystemPermissionError(error)
) {
// Retrying after a dismissal would show a second dialog.
onMediaPermissionError(error as Error)
bothReady()
return
}
// Combined requests fail atomically (e.g. missing webcam fails the
// mic too, and an OS-level block on one device fails both) — retry
// per kind to know which device is affected; permission is settled
// at the browser level, no dialog risk. The retries run in parallel
// and settle readiness independently.
void navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
stopAll(stream)
noteGumSuccess('microphone')
})
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
.finally(() =>
setState((current) => ({ ...current, audioReady: true }))
)
void navigator.mediaDevices
.getUserMedia({ video: true })
.then((stream) => {
stopAll(stream)
noteGumSuccess('camera')
})
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
.finally(() =>
setState((current) => ({ ...current, videoReady: true }))
)
}
}
warmup()
}, [])
return state
}
function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
ready,
enabled,
create,
permissionKind,
onFailure,
}: {
ready: boolean
enabled: boolean
create: () => Promise<T>
permissionKind: PermissionKind
onFailure: () => void
}): T | null {
const [track, setTrack] = useState<T | null>(null)
// Acquire.
useEffect(() => {
if (!ready || !enabled || track) {
return
}
let cancelled = false
create()
.then((newTrack) => {
noteGumSuccess(permissionKind)
if (cancelled) {
newTrack.stop()
return
}
setTrack(newTrack)
})
.catch((error) => {
onMediaPermissionError(error as Error, permissionKind)
onFailure()
})
return () => {
cancelled = true
}
}, [ready, enabled, track, create, permissionKind, onFailure])
// Release on toggle-off so the LED turns off.
useEffect(() => {
if (!enabled && track) {
track.stop()
setTrack(null)
}
}, [enabled, track])
// Track ended externally (permission revoked, device unplugged):
// disable instead of re-acquiring, so no unsolicited dialog.
useEffect(() => {
if (!track) {
return
}
const handleEnded = () => {
setTrack(null)
onFailure()
}
track.on(TrackEvent.Ended, handleEnded)
return () => {
track.off(TrackEvent.Ended, handleEnded)
}
}, [track, onFailure])
// Release on unmount or replacement.
useEffect(() => {
return () => {
track?.stop()
}
}, [track])
return track
}
export function useJoinTracks(): {
audioTrack: LocalAudioTrack | undefined
videoTrack: LocalVideoTrack | undefined
} {
const {
audioEnabled,
videoEnabled,
audioDeviceId,
videoDeviceId,
processorConfig,
} = useSnapshot(userChoicesStore)
const { audioReady, videoReady } = useWarmupPermissions()
const createAudio = useCallback(
() =>
createLocalAudioTrack({
deviceId: audioDeviceId,
...VOICE_AUDIO_CONSTRAINTS,
}),
[audioDeviceId]
)
const createVideo = useCallback(
() =>
createLocalVideoTrack({
deviceId: videoDeviceId,
processor:
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
}),
[videoDeviceId, processorConfig]
)
const audioTrack = useLocalTrack({
ready: audioReady,
enabled: audioEnabled,
create: createAudio,
permissionKind: 'microphone',
onFailure: disableAudio,
})
const videoTrack = useLocalTrack({
ready: videoReady,
enabled: videoEnabled,
create: createVideo,
permissionKind: 'camera',
onFailure: disableVideo,
})
useSyncTrackDeviceId(audioTrack ?? undefined, saveAudioInputDeviceId)
useSyncTrackDeviceId(videoTrack ?? undefined, saveVideoInputDeviceId)
return {
audioTrack: audioTrack ?? undefined,
videoTrack: videoTrack ?? undefined,
}
}
@@ -1,7 +1,6 @@
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useCallback } from 'react'
import { reportError } from '@/features/analytics/telemetry'
export const usePermissionsManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
@@ -11,6 +10,8 @@ export const usePermissionsManager = () => {
const roomId = data?.slug
const isMutingEnabled = configuration?.everyone_can_mute ?? true
const isAuthenticatedCanRecordEnabled =
configuration?.authenticated_can_record ?? true
const toggleMuting = useCallback(
async (enabled: boolean) => {
@@ -29,9 +30,33 @@ export const usePermissionsManager = () => {
return { configuration: newConfiguration }
} catch (error) {
reportError('permissions_api_failure', error, {
context: 'Failed to update muting permission:',
console.error('Failed to update muting permission:', error)
return { success: false, error }
}
},
[configuration, roomId, patchRoom]
)
const toggleAuthenticatedCanRecord = useCallback(
async (enabled: boolean) => {
if (!roomId) return
try {
const newConfiguration = {
...configuration,
authenticated_can_record: enabled,
}
const room = await patchRoom({
roomId,
room: { configuration: newConfiguration },
})
queryClient.setQueryData([keys.room, roomId], room)
return { configuration: newConfiguration }
} catch (error) {
console.error('Failed to update recording permission:', error)
return { success: false, error }
}
},
@@ -41,5 +66,7 @@ export const usePermissionsManager = () => {
return {
toggleMuting,
isMutingEnabled,
isAuthenticatedCanRecordEnabled,
toggleAuthenticatedCanRecord,
}
}
@@ -12,7 +12,6 @@ import {
NotificationType,
useNotifyParticipants,
} from '@/features/notifications'
import { reportError } from '@/features/analytics/telemetry'
export const updatePublishSources = (
currentSources: Source[],
@@ -109,9 +108,7 @@ export const usePublishSourcesManager = () => {
return { configuration: newConfiguration }
} catch (error) {
reportError('publish_sources_failure', error, {
context: `Failed to update ${sources}:`,
})
console.error(`Failed to update ${sources}:`, error)
return { success: false, error }
}
},
@@ -8,7 +8,6 @@ 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
@@ -80,9 +79,9 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
try {
await raiseHand(!isHandRaised)
} catch (e) {
reportError('generic_failure', e, {
context: 'toggle_raised_hand',
})
console.error(
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}`
)
}
}
@@ -0,0 +1,22 @@
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])
}
@@ -1,18 +1,28 @@
import { useTitle } from 'hoofd'
import { useRoomData } from './useRoomData'
import { useMemo } from 'react'
const APP_TITLE = import.meta.env.VITE_APP_TITLE ?? ''
/**
* Updates the browser tab title with the room id to help users easily find
* Updates the browser tab title with the room name to help users easily find
* the meeting tab among many open tabs. Works on both the join screen and
* once connected.
*/
export const useRoomPageTitle = (roomId?: string) => {
export const useRoomPageTitle = () => {
const roomData = useRoomData()
const pageTitle = useMemo(() => {
if (!roomId) return APP_TITLE
return `${APP_TITLE} - ${roomId}`
}, [roomId])
if (!roomData) {
return APP_TITLE
}
const roomLabel = roomData.name || roomData.slug || ''
if (!roomLabel) return APP_TITLE
return `${APP_TITLE} - ${roomLabel} `
}, [roomData])
useTitle(pageTitle)
}
@@ -1,40 +0,0 @@
import { useEffect } from 'react'
import { TrackEvent, type LocalTrack } from 'livekit-client'
/**
* Keeps the persisted preference aligned with the track's actual device.
* The track is the source of truth, not the store.
*
* Syncs on mount and TrackEvent.Restarted, covering acquisition/restart,
* setDeviceId switches, unmute re-acquisition, and browser fallback.
*
* Skip persisting the raw "default" alias: it means "follow the OS default"
* and resolving it would pin the preference to a concrete device.
*/
export const useSyncTrackDeviceId = (
track: LocalTrack | undefined,
save: (deviceId: string) => void
) => {
useEffect(() => {
if (!track) return
let cancelled = false
const sync = () => {
track
.getDeviceId(false)
.then((deviceId) => {
if (!cancelled && deviceId) {
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])
}
@@ -1,104 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { MediaDeviceFailure, RoomEvent } from 'livekit-client'
import {
PERMISSION_BY_DEVICE_KIND,
type PermissionDeniedScope,
type PermissionKind,
classifyPermissionError,
isLikelySystemNotFound,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
} from '@/stores/permissions'
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
import { captureMediaEvent } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os'
type MediaDeviceAlert = {
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
const capturePermissionsDenied = (
scope: PermissionDeniedScope,
kind?: PermissionKind
) =>
captureMediaEvent('permissions-denied', {
path: 'connect_publish',
kind,
denied_scope: scope,
os: getOS(),
})
/**
* Single owner of the room's media device errors
* (RoomEvent.MediaDevicesError): telemetry, the user-facing alert, device
* availability refresh, and browser- vs OS-level permission classification.
* Listens to the raw room event because onMediaDeviceFailure only exposes
* LiveKit's mapped enum, and the raw error is needed to tell a browser-level
* denial from an OS-level one (Chromium's "Permission denied by system",
* Firefox/macOS's NotFoundError).
*/
export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
clear: () => void
} => {
const room = useRoomContext()
const [alert, setAlert] = useState<MediaDeviceAlert>(NO_ALERT)
useEffect(() => {
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
const failure = MediaDeviceFailure.getFailure(error)
if (!failure) return
if (failure != MediaDeviceFailure.Other) {
void captureMediaEvent('media-device-error', {
log_code: 'media_devices_error_event',
path: 'connect_publish',
failure,
kind: kind ?? 'unknown',
})
}
if (!kind) return
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
switch (failure) {
case MediaDeviceFailure.DeviceInUse:
setAlert({ error: failure, kind })
break
case MediaDeviceFailure.NotFound:
void syncDeviceAvailability()
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
// settings, missing Android app permissions).
void isLikelySystemNotFound(error, permissionKind).then((system) => {
if (system) {
noteSystemPermissionDenied(permissionKind)
void capturePermissionsDenied('system', permissionKind)
} else {
setAlert({ error: failure, kind })
}
})
break
case MediaDeviceFailure.PermissionDenied:
void classifyPermissionError(error, permissionKind).then((scope) => {
void capturePermissionsDenied(scope, permissionKind)
if (scope === 'system') {
noteSystemPermissionDenied(permissionKind)
} else {
notePermissionDeniedFromGum(permissionKind)
}
})
break
default:
break
}
}
room.on(RoomEvent.MediaDevicesError, onDeviceError)
return () => {
room.off(RoomEvent.MediaDevicesError, onDeviceError)
}
}, [room])
const clear = useCallback(() => setAlert(NO_ALERT), [])
return { ...alert, clear }
}
@@ -12,8 +12,7 @@ import type { ToggleButtonProps } from '@/primitives/ToggleButton'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
const CONTROL_BAR_BREAKPOINT_WIDE = 1100
const CONTROL_BAR_BREAKPOINT_NARROW = 1050
const CONTROL_BAR_BREAKPOINT = 1100
const NavigationControls = ({
onPress,
@@ -66,9 +65,10 @@ export const LateralMenu = () => {
</DialogTrigger>
)
}
interface BreakpointObserverProps {
containerRef: RefObject<HTMLDivElement>
onWideChange: (isWide: boolean | null) => void
onWideChange: (isWide: boolean) => void
}
const BreakpointObserver = ({
@@ -76,20 +76,7 @@ const BreakpointObserver = ({
onWideChange,
}: BreakpointObserverProps) => {
const { width } = useSize(containerRef)
const [isWide, setIsWide] = useState<boolean | null>(null)
useEffect(() => {
if (!width) {
return
}
if (width > CONTROL_BAR_BREAKPOINT_WIDE) {
setIsWide(true)
} else if (width <= CONTROL_BAR_BREAKPOINT_NARROW) {
setIsWide(false)
} else {
setIsWide((prev) => (prev === null ? false : prev))
}
}, [width])
const isWide = width > CONTROL_BAR_BREAKPOINT
useEffect(() => {
onWideChange(isWide)
@@ -103,7 +90,7 @@ export const MoreOptions = ({
}: {
parentElement: RefObject<HTMLDivElement>
}) => {
const [isWide, setIsWide] = useState<boolean | null>(null)
const [isWide, setIsWide] = useState(false)
return (
<nav
@@ -120,7 +107,7 @@ export const MoreOptions = ({
containerRef={parentElement}
onWideChange={setIsWide}
/>
{isWide !== null && (isWide ? <NavigationControls /> : <LateralMenu />)}
{isWide ? <NavigationControls /> : <LateralMenu />}
</nav>
)
}
@@ -11,11 +11,9 @@ import { SidePanel } from '../components/SidePanel'
import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { ConnectionObserver } from '../components/ConnectionObserver'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os'
import { isFireFox } from '@/utils/livekit'
import { MediaStateObserver } from '../components/MediaStateObserver'
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
@@ -27,8 +25,6 @@ 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'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
/**
* @public
@@ -38,20 +34,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
SettingsComponent?: React.ComponentType
}
const getScreenSharePermissionDeniedScope = (
error: Error
): 'system' | 'user' | 'browser' | null => {
if (error.name === 'NotAllowedError') {
if (/by system/i.test(error.message)) return 'system'
if (/by user/i.test(error.message)) return 'user'
return 'browser'
}
if (error.name === 'NotFoundError' && isFireFox() && getOS() === 'macos') {
return 'system'
}
return null
}
/**
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
@@ -71,46 +53,17 @@ const getScreenSharePermissionDeniedScope = (
* @public
*/
export function VideoConference({ ...props }: VideoConferenceProps) {
useRoomPageTitle()
useNoiseReduction()
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
const handleDeviceError = ({
source,
error,
}: {
source: Track.Source
error: Error
}) => {
if (source === Track.Source.ScreenShare) {
const scope = getScreenSharePermissionDeniedScope(error)
if (scope) {
if (scope === 'system') setIsShareErrorVisible(true)
void captureMediaEvent('screen-share-permission-denied', {
at: 'ControlBar.onDeviceError',
source,
error_name: error.name,
error_message: error.message,
denied_scope: scope,
os: getOS(),
})
return
}
}
reportError('device_switch_failure', error, {
at: 'ControlBar.onDeviceError',
source,
})
}
return (
<>
<RoomMetadataSynchronizer />
<ConnectionObserver />
<SyncDevicePreferences />
<RoomSilentMicDetector />
<MediaStateObserver />
<ChatProvider />
<VideoResolutionSubscription />
@@ -136,7 +89,18 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<StageLayout />
)}
</RoomContentArea>
<ControlBar onDeviceError={handleDeviceError} />
<ControlBar
onDeviceError={(e) => {
console.error(e)
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<SidePanel />
</>
)}
@@ -6,6 +6,7 @@ import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter'
import { useMemo } from 'react'
import { DisconnectReason } from 'livekit-client'
import type { CandidateInfo } from '@/stores/connectionObserver'
// fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', {
@@ -47,6 +48,10 @@ const FeedbackRoute = () => {
const state = window.history.state
return {
room_id: state?.room_id as string,
pc_publisher: state?.pc_publisher as CandidateInfo,
pc_publisher_changes_count: state?.pc_publisher_changes_count as number,
pc_subscriber: state?.pc_subscriber as CandidateInfo,
pc_subscriber_changes_count: state?.pc_subscriber_changes_count as number,
}
}, [])
@@ -7,7 +7,6 @@ import { useUser } from '@/features/auth/api/useUser'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { Permissions } from '../components/Permissions'
import { SilentMicDialog } from '../components/SilentMicDialog'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import {
isRoomValid,
@@ -15,14 +14,11 @@ import {
} from '@/features/rooms/utils/isRoomValid'
import { useConfig } from '@/api/useConfig.ts'
import { LogLevel, setLogLevel } from 'livekit-client'
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
const BaseRoom = ({ children }: { children: ReactNode }) => {
return (
<UserAware>
<Permissions />
<SilentMicDialog />
{children}
</UserAware>
)
@@ -40,15 +36,12 @@ const Room = () => {
const { data } = useConfig()
useRoomPageTitle(roomId)
useEffect(() => {
const shouldSilenceLogs = data?.silence_livekit_debug_logs || false
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug)
}, [data?.silence_livekit_debug_logs])
useKeyboardShortcuts()
useWatchDeviceAvailability()
const clearRouterState = () => {
if (window?.history?.state) {
@@ -1,3 +0,0 @@
export const canTestAudioOutput = () =>
typeof HTMLMediaElement !== 'undefined' &&
'setSinkId' in HTMLMediaElement.prototype // Safari: no output routing
@@ -5,7 +5,6 @@ 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()
@@ -53,9 +52,7 @@ const CreatePopup = () => {
popupWindow.close()
})
} catch (error) {
reportError('generic_failure', error, {
context: 'Failed to create meeting room:',
})
console.error('Failed to create meeting room:', error)
}
}
if (isLoggedIn && callbackId) {
@@ -15,7 +15,6 @@ 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
@@ -109,7 +108,7 @@ const SettingsPopup = () => {
patchRoom({
roomId: roomSlug,
room: { configuration: newConfiguration },
}).catch((e) => reportError('generic_failure', e))
}).catch((e) => console.error(e))
}
const updateSource = (sources: Source[], enabled: boolean) => {
@@ -330,7 +329,7 @@ const SettingsPopup = () => {
patchRoom({
roomId: roomSlug,
room: { access_level: value as ApiAccessLevel },
}).catch((e) => reportError('generic_failure', e))
}).catch((e) => console.error(e))
}
items={[
{
@@ -1,6 +1,5 @@
import { authUrl } from '@/features/auth/utils/authUrl'
import { PopupMessageType, CallbackCreationRoomData } from './types'
import { reportError } from '@/features/analytics/telemetry'
export class PopupWindow {
private sendMessageToManager(
@@ -10,7 +9,7 @@ export class PopupWindow {
callback?: () => void
) {
if (!window.opener) {
reportError('generic_failure', new Error('No manager window found'))
console.error('No manager window found')
window.close()
return
}

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