mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-21 15:47:09 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8972c8dbb8 |
@@ -8,81 +8,6 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ✨(backend) accept form-urlencoded on the user token endpoint
|
|
||||||
- ✨(summary) configurable s3 region
|
|
||||||
- ⬆️(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
|
### Added
|
||||||
|
|
||||||
- ✨(summary) report exception type in failure analytics
|
- ✨(summary) report exception type in failure analytics
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Security is very important to us.
|
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.
|
We appreciate your effort to make Visio more secure.
|
||||||
|
|
||||||
|
|||||||
@@ -65,11 +65,6 @@ server {
|
|||||||
sub_filter_once off;
|
sub_filter_once off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /assets/mediapipe/wasm/ {
|
|
||||||
expires 30d;
|
|
||||||
add_header Cache-Control "public, max-age=2592000";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serve static files with caching
|
# Serve static files with caching
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
|
|||||||
@@ -50,24 +50,10 @@ paths:
|
|||||||
|
|
||||||
The application must be authorized for the user's email domain.
|
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.
|
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
|
operationId: generateToken
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
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:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/TokenRequest'
|
$ref: '#/components/schemas/TokenRequest'
|
||||||
@@ -131,19 +117,6 @@ paths:
|
|||||||
summary: Domain not authorized
|
summary: Domain not authorized
|
||||||
value:
|
value:
|
||||||
error: "This application is not authorized for this email domain."
|
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:
|
/rooms:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.27.0"
|
version = "1.24.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.27.0"
|
version = "1.24.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -198,3 +198,37 @@ class IsPresentInMeeting(permissions.BasePermission):
|
|||||||
return False
|
return False
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
return False
|
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"
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ class RoomConfiguration(BaseModel):
|
|||||||
|
|
||||||
can_publish_sources: list[TrackSource] | None = None
|
can_publish_sources: list[TrackSource] | None = None
|
||||||
everyone_can_mute: bool | None = None
|
everyone_can_mute: bool | None = None
|
||||||
|
authenticated_can_record: bool | None = None
|
||||||
|
|
||||||
model_config = {"extra": "forbid"}
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="start-recording",
|
url_path="start-recording",
|
||||||
permission_classes=[
|
permission_classes=[
|
||||||
permissions.HasPrivilegesOnRoom,
|
permissions.CanRecord,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@FeatureFlag.require("recording")
|
@FeatureFlag.require("recording")
|
||||||
@@ -421,11 +421,30 @@ class RoomViewSet(
|
|||||||
mode=mode,
|
mode=mode,
|
||||||
options=options.model_dump(exclude_none=True) if options else {},
|
options=options.model_dump(exclude_none=True) if options else {},
|
||||||
)
|
)
|
||||||
models.RecordingAccess.objects.create(
|
|
||||||
user=self.request.user,
|
if room.is_administrator_or_owner(request.user):
|
||||||
role=models.RoleChoices.OWNER,
|
models.RecordingAccess.objects.create(
|
||||||
recording=recording,
|
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):
|
except (DjangoValidationError, IntegrityError):
|
||||||
# DjangoValidationError covers the Python-level check (full_clean);
|
# DjangoValidationError covers the Python-level check (full_clean);
|
||||||
@@ -469,7 +488,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="stop-recording",
|
url_path="stop-recording",
|
||||||
permission_classes=[
|
permission_classes=[
|
||||||
permissions.HasPrivilegesOnRoom,
|
permissions.CanRecord,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@FeatureFlag.require("recording")
|
@FeatureFlag.require("recording")
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ from rest_framework import decorators, mixins, viewsets
|
|||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
)
|
)
|
||||||
from rest_framework import (
|
|
||||||
parsers as drf_parsers,
|
|
||||||
)
|
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
response as drf_response,
|
response as drf_response,
|
||||||
)
|
)
|
||||||
@@ -44,7 +41,6 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="token",
|
url_path="token",
|
||||||
url_name="token",
|
url_name="token",
|
||||||
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
|
|
||||||
)
|
)
|
||||||
@FeatureFlag.require("application")
|
@FeatureFlag.require("application")
|
||||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Tests for external API /token endpoint
|
|||||||
# pylint: disable=W0621
|
# pylint: disable=W0621
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
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():
|
def test_api_applications_generate_token_invalid_client_id():
|
||||||
"""Invalid client_id should return 401."""
|
"""Invalid client_id should return 401."""
|
||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.27.0"
|
version = "1.24.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+4
-4
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.27.0"
|
version = "1.24.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -2252,11 +2252,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.6.0"
|
version = "0.5.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
@@ -4,11 +4,6 @@ server {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
location ^~ /assets/mediapipe/wasm/ {
|
|
||||||
expires 30d;
|
|
||||||
add_header Cache-Control "public, max-age=2592000";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Serve static files with caching
|
# Serve static files with caching
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
|||||||
Generated
+89
-76
@@ -1,40 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.27.0",
|
"version": "1.24.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.27.0",
|
"version": "1.24.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
"@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/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@mediapipe/tasks-vision": "0.10.14",
|
"@mediapipe/tasks-vision": "0.10.35",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
|
"derive-valtio": "0.2.0",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.21.0",
|
"livekit-client": "2.20.0",
|
||||||
"posthog-js": "1.404.1",
|
"posthog-js": "1.395.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.10",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.48.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
@@ -742,28 +743,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.8.0",
|
"version": "1.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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"
|
"@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": {
|
"node_modules/@floating-ui/utils": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
||||||
@@ -966,12 +967,12 @@
|
|||||||
"license": "GPL-3.0+"
|
"license": "GPL-3.0+"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/components-core": {
|
"node_modules/@livekit/components-core": {
|
||||||
"version": "0.12.14",
|
"version": "0.12.13",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
|
||||||
"integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
|
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "1.7.6",
|
"@floating-ui/dom": "1.7.4",
|
||||||
"loglevel": "1.9.1",
|
"loglevel": "1.9.1",
|
||||||
"rxjs": "7.8.2"
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
@@ -979,17 +980,17 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"livekit-client": "^2.20.1",
|
"livekit-client": "^2.17.2",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/components-react": {
|
"node_modules/@livekit/components-react": {
|
||||||
"version": "2.9.23",
|
"version": "2.9.21",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
|
||||||
"integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
|
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-core": "0.12.14",
|
"@livekit/components-core": "0.12.13",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.0.12",
|
"jose": "^6.0.12",
|
||||||
@@ -999,8 +1000,8 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0",
|
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
|
||||||
"livekit-client": "^2.20.1",
|
"livekit-client": "^2.18.2",
|
||||||
"react": ">=18",
|
"react": ">=18",
|
||||||
"react-dom": ">=18",
|
"react-dom": ">=18",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
@@ -1027,9 +1028,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/protocol": {
|
"node_modules/@livekit/protocol": {
|
||||||
"version": "1.50.4",
|
"version": "1.46.6",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||||
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
|
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
@@ -1048,12 +1049,18 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mediapipe/tasks-vision": {
|
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
||||||
"version": "0.10.14",
|
"version": "0.10.14",
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@mediapipe/tasks-vision": {
|
||||||
|
"version": "0.10.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
||||||
|
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -1720,18 +1727,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/core": {
|
"node_modules/@posthog/core": {
|
||||||
"version": "1.48.3",
|
"version": "1.39.5",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz",
|
||||||
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/types": "^1.405.0"
|
"@posthog/types": "^1.392.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/types": {
|
"node_modules/@posthog/types": {
|
||||||
"version": "1.405.0",
|
"version": "1.392.0",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz",
|
||||||
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/overlays": {
|
"node_modules/@react-aria/overlays": {
|
||||||
@@ -4393,14 +4400,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-js": {
|
"node_modules/core-js": {
|
||||||
"version": "3.50.0",
|
"version": "3.39.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
|
||||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
},
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
@@ -4712,6 +4716,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/derive-valtio": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"valtio": ">=2.0.0-rc.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -6403,9 +6416,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.6",
|
"version": "26.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6422,7 +6435,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5 || ^6 || ^7"
|
"typescript": "^5 || ^6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"typescript": {
|
"typescript": {
|
||||||
@@ -8112,20 +8125,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.21.0",
|
"version": "2.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||||
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
|
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
"@livekit/protocol": "1.50.4",
|
"@livekit/protocol": "1.46.6",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"sdp-transform": "^2.15.0",
|
"sdp-transform": "^2.15.0",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"typed-emitter": "^2.1.0",
|
"typed-emitter": "^2.1.0",
|
||||||
"webrtc-adapter": "9.0.6"
|
"webrtc-adapter": "9.0.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/dom-mediacapture-record": "^1"
|
"@types/dom-mediacapture-record": "^1"
|
||||||
@@ -9123,17 +9136,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.404.1",
|
"version": "1.395.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||||
"integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
|
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||||
"license": "(Apache-2.0 AND MIT)",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.43.1",
|
"@posthog/core": "^1.38.0",
|
||||||
"@posthog/types": "^1.397.0",
|
"@posthog/types": "^1.391.1",
|
||||||
"core-js": "^3.49.0",
|
"core-js": "^3.38.1",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.29.3",
|
"preact": "^10.29.2",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.3.0"
|
"web-vitals": "^5.3.0"
|
||||||
}
|
}
|
||||||
@@ -9425,9 +9438,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.10",
|
"version": "17.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^7.29.2",
|
||||||
@@ -9437,7 +9450,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"i18next": ">= 26.2.0",
|
"i18next": ">= 26.2.0",
|
||||||
"react": ">= 16.8.0",
|
"react": ">= 16.8.0",
|
||||||
"typescript": "^5 || ^6 || ^7"
|
"typescript": "^5 || ^6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
@@ -11648,9 +11661,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
"version": "9.0.6",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
||||||
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sdp": "^3.2.0"
|
"sdp": "^3.2.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.27.0",
|
"version": "1.24.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -19,29 +19,30 @@
|
|||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
"@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/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@mediapipe/tasks-vision": "0.10.14",
|
"@mediapipe/tasks-vision": "0.10.35",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.101.1",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
|
"derive-valtio": "0.2.0",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.21.0",
|
"livekit-client": "2.20.0",
|
||||||
"posthog-js": "1.404.1",
|
"posthog-js": "1.395.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.10",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.48.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
|
|||||||
@@ -2,31 +2,24 @@ import { Button } from '@/primitives'
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
|
||||||
|
|
||||||
export const SoundTester = () => {
|
export const SoundTester = () => {
|
||||||
const { t } = useTranslation('settings')
|
const { t } = useTranslation('settings')
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
const { devices, activeDeviceId } = useMediaDeviceSelect({
|
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||||
kind: 'audiooutput',
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canTestAudioOutput() || !activeDeviceId) return
|
const updateActiveId = async (deviceId: string) => {
|
||||||
if (!devices.some((device) => device.deviceId === activeDeviceId)) return
|
try {
|
||||||
audioRef.current?.setSinkId(activeDeviceId).catch((error) => {
|
await audioRef?.current?.setSinkId(deviceId)
|
||||||
if (error instanceof DOMException && error.name === 'NotFoundError') {
|
} catch (error) {
|
||||||
return
|
console.error(`Error setting sinkId: ${error}`)
|
||||||
}
|
}
|
||||||
reportError(
|
}
|
||||||
'device_switch_failure',
|
updateActiveId(activeDeviceId)
|
||||||
new Error(`Error setting sinkId: ${error}`)
|
}, [activeDeviceId])
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [devices, activeDeviceId])
|
|
||||||
|
|
||||||
// prevent pausing the sound
|
// prevent pausing the sound
|
||||||
navigator.mediaSession.setActionHandler('pause', function () {})
|
navigator.mediaSession.setActionHandler('pause', function () {})
|
||||||
@@ -35,13 +28,9 @@ export const SoundTester = () => {
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
onPress={async () => {
|
onPress={() => {
|
||||||
try {
|
audioRef?.current?.play()
|
||||||
await audioRef?.current?.play()
|
setIsPlaying(true)
|
||||||
setIsPlaying(true)
|
|
||||||
} catch {
|
|
||||||
setIsPlaying(false)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
isDisabled={isPlaying}
|
isDisabled={isPlaying}
|
||||||
@@ -55,7 +44,7 @@ export const SoundTester = () => {
|
|||||||
{/* eslint-disable jsx-a11y/media-has-caption */}
|
{/* eslint-disable jsx-a11y/media-has-caption */}
|
||||||
<audio
|
<audio
|
||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
src="/sounds/uprise.mp3"
|
src="sounds/uprise.mp3"
|
||||||
onEnded={() => setIsPlaying(false)}
|
onEnded={() => setIsPlaying(false)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 { useEffect } from 'react'
|
||||||
|
import { useLocation } from 'wouter'
|
||||||
|
import { type PostHog } from 'posthog-js'
|
||||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
import { getPosthog } from '../utils'
|
|
||||||
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) => {
|
export const startAnalyticsSession = (data: ApiUser) => {
|
||||||
getPosthog().then((ph) => {
|
getPosthog().then((ph) => {
|
||||||
@@ -31,6 +38,7 @@ export const useAnalytics = ({
|
|||||||
flags_api_host,
|
flags_api_host,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
}: useAnalyticsProps) => {
|
}: useAnalyticsProps) => {
|
||||||
|
const [location] = useLocation()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,14 +49,6 @@ export const useAnalytics = ({
|
|||||||
api_host: host,
|
api_host: host,
|
||||||
flags_api_host: flags_api_host,
|
flags_api_host: flags_api_host,
|
||||||
person_profiles: 'always',
|
person_profiles: 'always',
|
||||||
capture_pageview: 'history_change',
|
|
||||||
capture_pageleave: true,
|
|
||||||
capture_exceptions: {
|
|
||||||
capture_unhandled_errors: true,
|
|
||||||
capture_unhandled_rejections: true,
|
|
||||||
capture_console_errors: true,
|
|
||||||
},
|
|
||||||
before_send: filterExceptions,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}, [id, host, flags_api_host, isDisabled])
|
}, [id, host, flags_api_host, isDisabled])
|
||||||
@@ -58,5 +58,12 @@ export const useAnalytics = ({
|
|||||||
startAnalyticsSession(user)
|
startAnalyticsSession(user)
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
|
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||||
|
useEffect(() => {
|
||||||
|
getPosthog().then((ph) => {
|
||||||
|
ph.capture('$pageview')
|
||||||
|
})
|
||||||
|
}, [location])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { updateUserPreferences } from './updateUserPreferences'
|
||||||
import { convertToBackendLanguage } from '@/utils/languages'
|
import { convertToBackendLanguage } from '@/utils/languages'
|
||||||
import { useUser } from './useUser'
|
import { useUser } from './useUser'
|
||||||
import { ApiError } from '@/api/ApiError.ts'
|
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
|
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
|
||||||
@@ -44,11 +42,6 @@ export const useSyncUserPreferencesWithBackend = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncBrowserPreferencesToBackend().catch((error) => {
|
syncBrowserPreferencesToBackend()
|
||||||
if (error instanceof ApiError && error.statusCode === 401) return
|
|
||||||
reportError('generic_failure', error, {
|
|
||||||
context: '[useSyncUserPreferencesWithBackend] Failed to sync:',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [i18n.language, isLoggedIn, user, mutateAsync])
|
}, [i18n.language, isLoggedIn, user, mutateAsync])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChatRow } from '@/stores/chat'
|
import { ChatRow } from '@/stores/chat'
|
||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { formatChatMessageLinks } from '../utils'
|
import { formatChatMessageLinks } from '@livekit/components-react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const ChatProvider = () => {
|
|||||||
resetChatStore()
|
resetChatStore()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Trigger the message notification (temporary)
|
// Tigger the message notification (temporary)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||||
|
|||||||
@@ -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 { useConfig } from '@/api/useConfig'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||||
import { captureEvent } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
@@ -161,11 +160,7 @@ const Home = () => {
|
|||||||
window.location.replace(data.external_home_url)
|
window.location.replace(data.external_home_url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRedirectFailed(true)
|
setRedirectFailed(true)
|
||||||
captureEvent('external-home-unreachable', {
|
console.error('Site is not reachable:', error)
|
||||||
error_name: error instanceof Error ? error.name : 'Unknown',
|
|
||||||
error_message:
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { NotificationType } from '@/features/notifications/NotificationType
|
|||||||
// fixme - handle dynamic audio output changes
|
// fixme - handle dynamic audio output changes
|
||||||
export const useNotificationSound = () => {
|
export const useNotificationSound = () => {
|
||||||
const notificationsSnap = useSnapshot(notificationsStore)
|
const notificationsSnap = useSnapshot(notificationsStore)
|
||||||
const [play] = useSound('/sounds/notifications.mp3', {
|
const [play] = useSound('./sounds/notifications.mp3', {
|
||||||
sprite: {
|
sprite: {
|
||||||
participantJoined: [0, 1150],
|
participantJoined: [0, 1150],
|
||||||
handRaised: [1400, 180],
|
handRaised: [1400, 180],
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { NotificationDuration } from './NotificationDuration'
|
|||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import type { NotificationPayload } from './NotificationPayload'
|
import type { NotificationPayload } from './NotificationPayload'
|
||||||
import type { RecordingMode } from '@/features/recording'
|
import type { RecordingMode } from '@/features/recording'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const notifyAutoMutedOnJoin = () => {
|
export const notifyAutoMutedOnJoin = () => {
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
@@ -56,9 +55,7 @@ export const decodeNotificationDataReceived = (
|
|||||||
return parsed as NotificationPayload
|
return parsed as NotificationPayload
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle errors appropriately for your application
|
// Handle errors appropriately for your application
|
||||||
reportError('generic_failure', error, {
|
console.error('Failed to decode notification payload:', error)
|
||||||
context: 'Failed to decode notification payload:',
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useLowerHandParticipant } from './lowerHandParticipant'
|
import { useLowerHandParticipant } from './lowerHandParticipant'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const useLowerHandParticipants = () => {
|
export const useLowerHandParticipants = () => {
|
||||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||||
@@ -12,9 +11,7 @@ export const useLowerHandParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
console.error('An error occurred while lowering hands :', error)
|
||||||
context: 'An error occurred while lowering hands :',
|
|
||||||
})
|
|
||||||
throw new Error('An error occurred while lowering hands.', {
|
throw new Error('An error occurred while lowering hands.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const useParticipantRole = () => {
|
export const useParticipantRole = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -23,11 +22,8 @@ export const useParticipantRole = () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError(
|
console.error(
|
||||||
'generic_failure',
|
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
new Error(
|
|
||||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from '../../participants/api/listWaitingParticipants'
|
} from '../../participants/api/listWaitingParticipants'
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 1000
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ export const useWaitingParticipants = () => {
|
|||||||
|
|
||||||
await refetchWaiting()
|
await refetchWaiting()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reportError('generic_failure', e)
|
console.error(e)
|
||||||
setListEnabled(true)
|
setListEnabled(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import { useEffect, useMemo } from 'react'
|
|||||||
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
|
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
|
||||||
|
|
||||||
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
||||||
const pipStoreSnap = useSnapshot(documentPictureInPictureStore, {
|
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
|
||||||
sync: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const container = useMemo(() => {
|
const container = useMemo(() => {
|
||||||
return pipStoreSnap?.window?.document.getElementById('root')
|
return pipStoreSnap?.window?.document.getElementById('root')
|
||||||
@@ -21,7 +19,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!container || !container.isConnected) return null
|
if (!container) return null
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { ref, useSnapshot } from 'valtio'
|
import { ref, useSnapshot } from 'valtio'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { flushSync } from 'react-dom'
|
|
||||||
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
|
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const IS_PIP_SUPPORTED =
|
export const IS_PIP_SUPPORTED =
|
||||||
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
|
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
|
||||||
@@ -61,29 +59,21 @@ export const usePictureInPicture = () => {
|
|||||||
if (!IS_PIP_SUPPORTED) return null
|
if (!IS_PIP_SUPPORTED) return null
|
||||||
if (isOpen) return null
|
if (isOpen) return null
|
||||||
|
|
||||||
let pipWindow: Window
|
|
||||||
try {
|
try {
|
||||||
pipWindow =
|
const pipWindow =
|
||||||
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(window as any).documentPictureInPicture.requestWindow({
|
(window as any).documentPictureInPicture.requestWindow({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
})
|
})
|
||||||
} catch {
|
|
||||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
initializeTitleAndLanguage(pipWindow, t('title'))
|
initializeTitleAndLanguage(pipWindow, t('title'))
|
||||||
initializePortalContainer(pipWindow)
|
initializePortalContainer(pipWindow)
|
||||||
syncStyles(pipWindow)
|
syncStyles(pipWindow)
|
||||||
|
|
||||||
const cleanUp = () => {
|
const cleanUp = () => {
|
||||||
if (documentPictureInPictureStore.window === pipWindow) {
|
if (documentPictureInPictureStore.window === pipWindow) {
|
||||||
flushSync(() => {
|
documentPictureInPictureStore.window = null
|
||||||
documentPictureInPictureStore.window = null
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
|
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
|
||||||
@@ -92,10 +82,8 @@ export const usePictureInPicture = () => {
|
|||||||
})
|
})
|
||||||
documentPictureInPictureStore.window = ref(pipWindow)
|
documentPictureInPictureStore.window = ref(pipWindow)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||||
context: 'pip_init_failure',
|
console.error('Failed to open Picture-in-Picture window', error)
|
||||||
})
|
|
||||||
pipWindow.close()
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ const StyledContainer = styled('div', {
|
|||||||
backgroundColor: 'primaryDark.100',
|
backgroundColor: 'primaryDark.100',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
translate: '0 3.25rem',
|
transform: 'translateY(3.25rem)',
|
||||||
transition: 'opacity, translate',
|
transition: 'opacity, transform',
|
||||||
transitionDuration: '0.5s',
|
transitionDuration: '0.5s',
|
||||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
@@ -36,7 +36,7 @@ const StyledContainer = styled('div', {
|
|||||||
isVisible: {
|
isVisible: {
|
||||||
true: {
|
true: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
translate: '0 0',
|
transform: 'translateY(0)',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
shouldBeCenteredWithToggleButton,
|
shouldBeCenteredWithToggleButton,
|
||||||
setShouldBeCenteredWithToggleButton,
|
setShouldBeCenteredWithToggleButton,
|
||||||
] = useState(false)
|
] = useState(false)
|
||||||
const [offsetX, setOffsetX] = useState(0)
|
const [rightOffset, setRightOffset] = useState(0)
|
||||||
|
|
||||||
const updateArrows = useCallback(() => {
|
const updateArrows = useCallback(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
@@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!shouldBeCenteredWithToggleButton || isMobile) {
|
if (!shouldBeCenteredWithToggleButton || isMobile) {
|
||||||
setOffsetX(0)
|
setRightOffset(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
const containerCenterX = containerRect.left + containerRect.width / 2
|
const containerCenterX = containerRect.left + containerRect.width / 2
|
||||||
const shift = toggleCenterX - containerCenterX
|
const shift = toggleCenterX - containerCenterX
|
||||||
if (Math.abs(shift) < 0.5) return
|
if (Math.abs(shift) < 0.5) return
|
||||||
setOffsetX((prev) => prev + shift)
|
setRightOffset((prev) => prev - shift * 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
style={
|
style={
|
||||||
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
||||||
? { transform: `translateX(${offsetX}px)` }
|
? { marginRight: `${rightOffset}px` }
|
||||||
: { margin: '0 15px' }
|
: { margin: '0 15px' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export const NoAccessView = ({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
{/*todo - update the message here to adapt to room configuration */}
|
||||||
{!isLoggedIn && (
|
{!isLoggedIn && (
|
||||||
<LoginPrompt
|
<LoginPrompt
|
||||||
heading={t(`${i18nKey}.login.heading`)}
|
heading={t(`${i18nKey}.login.heading`)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
notifyRecordingSaveInProgress,
|
notifyRecordingSaveInProgress,
|
||||||
useNotifyParticipants,
|
useNotifyParticipants,
|
||||||
} from '@/features/notifications'
|
} from '@/features/notifications'
|
||||||
|
import posthog from 'posthog-js'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { NoAccessView } from './NoAccessView'
|
import { NoAccessView } from './NoAccessView'
|
||||||
import { ControlsButton } from './ControlsButton'
|
import { ControlsButton } from './ControlsButton'
|
||||||
@@ -28,7 +29,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { FeatureFlags } from '@/features/analytics/enums'
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
import { useCanRecord } from '@/features/recording/hooks/useCanRecord'
|
||||||
|
|
||||||
export const ScreenRecordingSidePanel = () => {
|
export const ScreenRecordingSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -38,6 +39,7 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
|
|
||||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||||
|
|
||||||
|
const canRecord = useCanRecord()
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
|
|
||||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||||
@@ -63,7 +65,7 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingRequested,
|
type: NotificationType.ScreenRecordingRequested,
|
||||||
})
|
})
|
||||||
captureEvent('screen-recording-requested', {})
|
posthog.capture('screen-recording-requested', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScreenRecording = async () => {
|
const handleScreenRecording = async () => {
|
||||||
@@ -100,19 +102,17 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingStarted,
|
type: NotificationType.ScreenRecordingStarted,
|
||||||
})
|
})
|
||||||
captureEvent('screen-recording-started', {
|
posthog.capture('screen-recording-started', {
|
||||||
includeTranscript: includeTranscript,
|
includeTranscript: includeTranscript,
|
||||||
language: selectedLanguageKey,
|
language: selectedLanguageKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
console.error('Failed to handle recording:', error)
|
||||||
context: 'Failed to handle recording:',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdminOrOwner) {
|
if (!canRecord) {
|
||||||
return (
|
return (
|
||||||
<NoAccessView
|
<NoAccessView
|
||||||
i18nKeyPrefix={keyPrefix}
|
i18nKeyPrefix={keyPrefix}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRoomContext } from '@livekit/components-react'
|
|||||||
import {
|
import {
|
||||||
RecordingMode,
|
RecordingMode,
|
||||||
useHasRecordingAccess,
|
useHasRecordingAccess,
|
||||||
useHasFeatureWithoutAdminRights,
|
useHasFeatureWithoutRecordingRights,
|
||||||
useRecordingStatuses,
|
useRecordingStatuses,
|
||||||
} from '../index'
|
} from '../index'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useNotifyParticipants,
|
useNotifyParticipants,
|
||||||
notifyRecordingSaveInProgress,
|
notifyRecordingSaveInProgress,
|
||||||
} from '@/features/notifications'
|
} from '@/features/notifications'
|
||||||
|
import posthog from 'posthog-js'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { VStack } from '@/styled-system/jsx'
|
import { VStack } from '@/styled-system/jsx'
|
||||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||||
@@ -34,7 +35,6 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
import { openSettingsDialog } from '@/stores/settings'
|
||||||
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const TranscriptSidePanel = () => {
|
export const TranscriptSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -53,7 +53,7 @@ export const TranscriptSidePanel = () => {
|
|||||||
FeatureFlags.Transcript
|
FeatureFlags.Transcript
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
const hasFeatureWithoutAdminRights = useHasFeatureWithoutRecordingRights(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
FeatureFlags.Transcript
|
FeatureFlags.Transcript
|
||||||
)
|
)
|
||||||
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionRequested,
|
type: NotificationType.TranscriptionRequested,
|
||||||
})
|
})
|
||||||
captureEvent('transcript-requested', {})
|
posthog.capture('transcript-requested', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTranscript = async () => {
|
const handleTranscript = async () => {
|
||||||
@@ -121,15 +121,13 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionStarted,
|
type: NotificationType.TranscriptionStarted,
|
||||||
})
|
})
|
||||||
captureEvent('transcript-started', {
|
posthog.capture('transcript-started', {
|
||||||
includeScreenRecording: includeScreenRecording,
|
includeScreenRecording: includeScreenRecording,
|
||||||
language: selectedLanguageKey,
|
language: selectedLanguageKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
console.error('Failed to handle transcript:', error)
|
||||||
context: 'Failed to handle transcript:',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
+4
-4
@@ -2,21 +2,21 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
|
|||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||||
import type { RecordingMode } from '../types'
|
import type { RecordingMode } from '../types'
|
||||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useCanRecord } from './useCanRecord'
|
||||||
import type { FeatureFlags } from '@/features/analytics/enums'
|
import type { FeatureFlags } from '@/features/analytics/enums'
|
||||||
|
|
||||||
export const useHasFeatureWithoutAdminRights = (
|
export const useHasFeatureWithoutRecordingRights = (
|
||||||
mode: RecordingMode,
|
mode: RecordingMode,
|
||||||
featureFlag: FeatureFlags
|
featureFlag: FeatureFlags
|
||||||
) => {
|
) => {
|
||||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
const canRecord = useCanRecord()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(featureEnabled || !isAnalyticsEnabled) &&
|
(featureEnabled || !isAnalyticsEnabled) &&
|
||||||
isRecordingModeEnabled &&
|
isRecordingModeEnabled &&
|
||||||
!isAdminOrOwner
|
!canRecord
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,8 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
|
|||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||||
import type { RecordingMode } from '../types'
|
import type { RecordingMode } from '../types'
|
||||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
|
||||||
import type { FeatureFlags } from '@/features/analytics/enums'
|
import type { FeatureFlags } from '@/features/analytics/enums'
|
||||||
|
import { useCanRecord } from './useCanRecord'
|
||||||
|
|
||||||
export const useHasRecordingAccess = (
|
export const useHasRecordingAccess = (
|
||||||
mode: RecordingMode,
|
mode: RecordingMode,
|
||||||
@@ -12,11 +12,11 @@ export const useHasRecordingAccess = (
|
|||||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
const canRecord = useCanRecord()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(featureEnabled || !isAnalyticsEnabled) &&
|
(featureEnabled || !isAnalyticsEnabled) &&
|
||||||
isAdminOrOwner &&
|
canRecord &&
|
||||||
isRecordingModeEnabled
|
isRecordingModeEnabled
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { useStartRecording, useStopRecording } from '@/features/recording'
|
import { useStartRecording, useStopRecording } from '@/features/recording'
|
||||||
import { recordingStore } from '@/stores/recording'
|
import { recordingStore } from '@/stores/recording'
|
||||||
import { captureEvent } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const useMutateRecording = () => {
|
export const useMutateRecording = () => {
|
||||||
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
||||||
useStartRecording({
|
useStartRecording({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
recordingStore.isErrorDialogOpen = 'start'
|
recordingStore.isErrorDialogOpen = 'start'
|
||||||
captureEvent('error-starting-recording')
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
||||||
useStopRecording({
|
useStopRecording({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
recordingStore.isErrorDialogOpen = 'stop'
|
recordingStore.isErrorDialogOpen = 'stop'
|
||||||
captureEvent('error-stopping-recording')
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useRoomInfo } from '@livekit/components-react'
|
import { useRoomInfo } from '@livekit/components-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const useRoomMetadata = () => {
|
export const useRoomMetadata = () => {
|
||||||
const { metadata } = useRoomInfo()
|
const { metadata } = useRoomInfo()
|
||||||
@@ -9,9 +8,7 @@ export const useRoomMetadata = () => {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(metadata)
|
return JSON.parse(metadata)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
console.error('Failed to parse room metadata:', error)
|
||||||
context: 'Failed to parse room metadata:',
|
|
||||||
})
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// hooks
|
// hooks
|
||||||
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
||||||
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
||||||
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
|
export { useHasFeatureWithoutRecordingRights } from './hooks/useHasFeatureWithoutRecordingRights.ts'
|
||||||
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
|
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
|
||||||
|
|
||||||
// api
|
// api
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export enum ApiAccessLevel {
|
|||||||
export type RoomConfiguration = {
|
export type RoomConfiguration = {
|
||||||
can_publish_sources?: Source[] | null
|
can_publish_sources?: Source[] | null
|
||||||
everyone_can_mute?: boolean | null
|
everyone_can_mute?: boolean | null
|
||||||
|
authenticated_can_record?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ export const fetchRoom = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const query = username ? `?username=${encodeURIComponent(username)}` : ''
|
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 { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
||||||
|
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const useMuteParticipant = () => {
|
export const useMuteParticipant = () => {
|
||||||
const apiRoomData = useRoomData()
|
const apiRoomData = useRoomData()
|
||||||
@@ -32,10 +31,7 @@ export const useMuteParticipant = () => {
|
|||||||
|
|
||||||
// Guard against undefined token for non-admin users
|
// Guard against undefined token for non-admin users
|
||||||
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
|
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
|
||||||
reportError(
|
console.error('Cannot mute participant: missing auth token')
|
||||||
'participant_mute_api_failure',
|
|
||||||
new Error('Cannot mute participant: missing auth token')
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +53,8 @@ export const useMuteParticipant = () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError(
|
console.error(
|
||||||
'participant_mute_api_failure',
|
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
new Error(
|
|
||||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -72,11 +65,8 @@ export const useMuteParticipant = () => {
|
|||||||
destinationIdentities: [participant.identity],
|
destinationIdentities: [participant.identity],
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reportError(
|
console.error(
|
||||||
'participant_mute_api_failure',
|
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||||
new Error(
|
|
||||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useMuteParticipant } from './muteParticipant'
|
import { useMuteParticipant } from './muteParticipant'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const useMuteParticipants = () => {
|
export const useMuteParticipants = () => {
|
||||||
const { muteParticipant } = useMuteParticipant()
|
const { muteParticipant } = useMuteParticipant()
|
||||||
@@ -12,9 +11,7 @@ export const useMuteParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('participant_mute_api_failure', error, {
|
console.error('An error occurred while muting participants :', error)
|
||||||
context: 'An error occurred while muting participants :',
|
|
||||||
})
|
|
||||||
throw new Error('An error occurred while muting participants.', {
|
throw new Error('An error occurred while muting participants.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Participant, Track } from 'livekit-client'
|
import type { Participant, Track } from 'livekit-client'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
export const useParticipantPermissions = () => {
|
export const useParticipantPermissions = () => {
|
||||||
@@ -33,11 +32,8 @@ export const useParticipantPermissions = () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError(
|
console.error(
|
||||||
'permissions_api_failure',
|
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
new 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 type { Participant, Track } from 'livekit-client'
|
||||||
import { useParticipantPermissions } from './updateParticipantPermissions'
|
import { useParticipantPermissions } from './updateParticipantPermissions'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
export const useUpdateParticipantsPermissions = () => {
|
export const useUpdateParticipantsPermissions = () => {
|
||||||
@@ -16,9 +15,7 @@ export const useUpdateParticipantsPermissions = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('permissions_api_failure', error, {
|
console.error('An error occurred while updating permissions :', error)
|
||||||
context: 'An error occurred while updating permissions :',
|
|
||||||
})
|
|
||||||
throw new Error('An error occurred while updating permissions.', {
|
throw new Error('An error occurred while updating permissions.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
usePersistentUserChoices,
|
usePersistentUserChoices,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
ConnectionError,
|
|
||||||
ConnectionErrorReason,
|
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
Room,
|
Room,
|
||||||
@@ -27,21 +25,18 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||||
import { LocalUserChoices } from '@/stores/userChoices'
|
import { LocalUserChoices } from '@/stores/userChoices'
|
||||||
import {
|
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||||
captureEvent,
|
import { usePostHog } from 'posthog-js/react'
|
||||||
captureMediaEvent,
|
|
||||||
reportError,
|
|
||||||
} from '@/features/analytics/telemetry'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { isFireFox } from '@/utils/livekit'
|
import { isFireFox } from '@/utils/livekit'
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
|
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||||
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
|
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
|
||||||
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
import { userStore } from '@/stores/user'
|
import { userStore } from '@/stores/user'
|
||||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -52,6 +47,7 @@ export const Conference = ({
|
|||||||
mode?: 'join' | 'create'
|
mode?: 'join' | 'create'
|
||||||
initialRoomData?: ApiRoom
|
initialRoomData?: ApiRoom
|
||||||
}) => {
|
}) => {
|
||||||
|
const posthog = usePostHog()
|
||||||
const { data: apiConfig } = useConfig()
|
const { data: apiConfig } = useConfig()
|
||||||
|
|
||||||
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||||
@@ -61,8 +57,8 @@ export const Conference = ({
|
|||||||
const { username } = useSnapshot(userStore)
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void captureMediaEvent('visit-room', { slug: roomId })
|
posthog.capture('visit-room', { slug: roomId })
|
||||||
}, [roomId])
|
}, [roomId, posthog])
|
||||||
const fetchKey = [keys.room, roomId]
|
const fetchKey = [keys.room, roomId]
|
||||||
|
|
||||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||||
@@ -174,6 +170,14 @@ export const Conference = ({
|
|||||||
prepareConnection()
|
prepareConnection()
|
||||||
}, [room, apiConfig, isConnectionWarmedUp])
|
}, [room, apiConfig, isConnectionWarmedUp])
|
||||||
|
|
||||||
|
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||||
|
error: MediaDeviceFailure | null
|
||||||
|
kind: MediaDeviceKind | null
|
||||||
|
}>({
|
||||||
|
error: null,
|
||||||
|
kind: null,
|
||||||
|
})
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const hasAutoMutedRef = useRef(false)
|
const hasAutoMutedRef = useRef(false)
|
||||||
@@ -231,21 +235,7 @@ export const Conference = ({
|
|||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const failure = MediaDeviceFailure.getFailure(e)
|
posthog.captureException(e)
|
||||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
|
||||||
|
|
||||||
// connect() was aborted by a disconnect() before the join completed
|
|
||||||
if (
|
|
||||||
e instanceof ConnectionError &&
|
|
||||||
e.reason === ConnectionErrorReason.Cancelled
|
|
||||||
) {
|
|
||||||
void captureEvent('connection-cancelled')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reportError('livekit_room_error', e, {
|
|
||||||
path: 'connect_publish',
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
onConnected={async () => {
|
onConnected={async () => {
|
||||||
if (!apiConfig) return
|
if (!apiConfig) return
|
||||||
@@ -263,8 +253,23 @@ export const Conference = ({
|
|||||||
onDisconnected={(e) => {
|
onDisconnected={(e) => {
|
||||||
const metadata = {
|
const metadata = {
|
||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
|
pc_publisher: connectionObserverStore.publisher && {
|
||||||
|
...connectionObserverStore.publisher,
|
||||||
|
},
|
||||||
|
pc_subscriber: connectionObserverStore.subscriber && {
|
||||||
|
...connectionObserverStore.subscriber,
|
||||||
|
},
|
||||||
|
pc_publisher_changes_count:
|
||||||
|
connectionObserverStore.publisherChangesCount,
|
||||||
|
pc_subscriber_changes_count:
|
||||||
|
connectionObserverStore.subscriberChangesCount,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connectionObserverStore.publisher = null
|
||||||
|
connectionObserverStore.publisherChangesCount = 0
|
||||||
|
connectionObserverStore.subscriber = null
|
||||||
|
connectionObserverStore.subscriberChangesCount = 0
|
||||||
|
|
||||||
switch (e) {
|
switch (e) {
|
||||||
case DisconnectReason.CLIENT_INITIATED:
|
case DisconnectReason.CLIENT_INITIATED:
|
||||||
navigateTo(
|
navigateTo(
|
||||||
@@ -290,10 +295,18 @@ export const Conference = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onMediaDeviceFailure={(e, kind) => {
|
||||||
|
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
|
||||||
|
setMediaDeviceError({ error: e, kind })
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<WatchMediaDeviceErrors />
|
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{!isMobile && <InviteDialog mode={mode} />}
|
{!isMobile && <InviteDialog mode={mode} />}
|
||||||
|
<MediaDeviceErrorAlert
|
||||||
|
{...mediaDeviceError}
|
||||||
|
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||||
|
/>
|
||||||
<PictureInPictureConference />
|
<PictureInPictureConference />
|
||||||
</LiveKitRoom>
|
</LiveKitRoom>
|
||||||
</Screen>
|
</Screen>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
|
const roomUrl = getRouteUrl('room', roomData?.slug)
|
||||||
|
|
||||||
const telephony = useTelephony()
|
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 { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Button, Dialog, H, P } from '@/primitives'
|
import { Dialog, H } from '@/primitives'
|
||||||
import { RiEqualizer2Line } from '@remixicon/react'
|
import { RiEqualizer2Line } from '@remixicon/react'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import {
|
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
|
||||||
closePermissionsDialog,
|
|
||||||
closeSystemPermissionsDialog,
|
|
||||||
permissionsStore,
|
|
||||||
} from '@/stores/permissions'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { injectIconIntoTranslation } from '@/utils/translation'
|
import { injectIconIntoTranslation } from '@/utils/translation'
|
||||||
import { isSafari } from '@/utils/livekit'
|
import { isSafari } from '@/utils/livekit'
|
||||||
import { type OS, getOS } from '@/utils/os'
|
|
||||||
|
|
||||||
type StepsOs = 'macos' | 'windows' | 'android' | 'other'
|
|
||||||
|
|
||||||
const STEPS_OS: Record<OS, StepsOs> = {
|
|
||||||
macos: 'macos',
|
|
||||||
windows: 'windows',
|
|
||||||
android: 'android',
|
|
||||||
linux: 'other',
|
|
||||||
other: 'other',
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSystemSettingsUrl = (os: OS, label: string): string | null => {
|
|
||||||
if (os === 'macos') {
|
|
||||||
if (label === 'camera')
|
|
||||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'
|
|
||||||
if (label === 'microphone')
|
|
||||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
|
|
||||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy'
|
|
||||||
}
|
|
||||||
if (os === 'windows') {
|
|
||||||
if (label === 'camera') return 'ms-settings:privacy-webcam'
|
|
||||||
if (label === 'microphone') return 'ms-settings:privacy-microphone'
|
|
||||||
return 'ms-settings:privacy'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const SystemPermissions = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'systemPermissionDialog' })
|
|
||||||
const permissions = useSnapshot(permissionsStore)
|
|
||||||
const os = useMemo(() => getOS() || 'other', [])
|
|
||||||
|
|
||||||
const label = useMemo(() => {
|
|
||||||
if (permissions.microphoneSystemDenied && permissions.cameraSystemDenied) {
|
|
||||||
return 'cameraAndMicrophone'
|
|
||||||
}
|
|
||||||
if (permissions.cameraSystemDenied) return 'camera'
|
|
||||||
return 'microphone'
|
|
||||||
}, [permissions])
|
|
||||||
|
|
||||||
const isOpen = permissions.isSystemPermissionDialogOpen
|
|
||||||
|
|
||||||
// Auto-close once access works again (the user fixed the OS settings).
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
isOpen &&
|
|
||||||
!permissions.microphoneSystemDenied &&
|
|
||||||
!permissions.cameraSystemDenied
|
|
||||||
) {
|
|
||||||
closeSystemPermissionsDialog()
|
|
||||||
}
|
|
||||||
}, [isOpen, permissions])
|
|
||||||
|
|
||||||
const device = t(`device.${label}`)
|
|
||||||
const settingsUrl = getSystemSettingsUrl(os, label)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
isOpen={isOpen}
|
|
||||||
role="dialog"
|
|
||||||
type="flex"
|
|
||||||
title=""
|
|
||||||
aria-label={t(`heading.${label}`)}
|
|
||||||
onClose={closeSystemPermissionsDialog}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
maxWidth: '500px',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<H lvl={1}>{t(`heading.${label}`)}</H>
|
|
||||||
<P>{t('intro', { device })}</P>
|
|
||||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
|
||||||
{Array.from({ length: 2 }, (_, index) => (
|
|
||||||
<li key={index}>
|
|
||||||
{t(`steps.${STEPS_OS[os] || 'other'}.${index + 1}`, { device })}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
{settingsUrl && (
|
|
||||||
<div className={css({ marginTop: '2rem' })}>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onPress={() => {
|
|
||||||
window.open(settingsUrl, '_blank')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('openSettings')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton component - ensures permissions sync runs only once across the app.
|
* Singleton component - ensures permissions sync runs only once across the app.
|
||||||
@@ -166,74 +65,68 @@ export const Permissions = () => {
|
|||||||
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
|
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog
|
||||||
<SystemPermissions />
|
isOpen={permissions.isPermissionDialogOpen}
|
||||||
<Dialog
|
role="dialog"
|
||||||
isOpen={permissions.isPermissionDialogOpen}
|
type="flex"
|
||||||
role="dialog"
|
title=""
|
||||||
type="flex"
|
aria-label={t(`heading.${permissionLabel}`, {
|
||||||
title=""
|
appTitle,
|
||||||
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
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
maxWidth: '400px',
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexDirection: 'column',
|
|
||||||
md: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
},
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<img
|
<H lvl={2}>
|
||||||
src="/assets/camera_mic_permission.svg"
|
{t(`heading.${permissionLabel}`, {
|
||||||
alt=""
|
appTitle,
|
||||||
className={css({
|
|
||||||
width: '100%',
|
|
||||||
minHeight: '290px',
|
|
||||||
maxWidth: '290px',
|
|
||||||
})}
|
})}
|
||||||
/>
|
</H>
|
||||||
<div
|
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||||
className={css({
|
<li>
|
||||||
maxWidth: '400px',
|
{isSafari() ? (
|
||||||
})}
|
t('body.openMenu.safari', {
|
||||||
>
|
appDomain: window.origin.replace('https://', ''),
|
||||||
<H lvl={2}>
|
})
|
||||||
{t(`heading.${permissionLabel}`, {
|
) : (
|
||||||
appTitle,
|
<>
|
||||||
})}
|
{descriptionBeforeIcon}
|
||||||
</H>
|
<span
|
||||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
||||||
<li>
|
>
|
||||||
{isSafari() ? (
|
<RiEqualizer2Line />
|
||||||
t('body.openMenu.safari', {
|
</span>
|
||||||
appDomain: window.origin.replace('https://', ''),
|
{descriptionAfterIcon}
|
||||||
})
|
</>
|
||||||
) : (
|
)}
|
||||||
<>
|
</li>
|
||||||
{descriptionBeforeIcon}
|
<li>{t(`body.details.${permissionLabel}`)}</li>
|
||||||
<span
|
</ol>
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RiEqualizer2Line />
|
|
||||||
</span>
|
|
||||||
{descriptionAfterIcon}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
<li>{t(`body.details.${permissionLabel}`)}</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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 { useEffect, useMemo, useState } from 'react'
|
||||||
import { cva } from '@/styled-system/css'
|
import { cva } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { styled, VStack } from '@/styled-system/jsx'
|
import { styled, VStack } from '@/styled-system/jsx'
|
||||||
|
import { usePostHog } from 'posthog-js/react'
|
||||||
|
import type { PostHog } from 'posthog-js'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||||
import { captureEvent } from '@/features/analytics/telemetry'
|
import type { CandidateInfo } from '@/stores/connectionObserver'
|
||||||
|
|
||||||
const Card = styled('div', {
|
const Card = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
@@ -70,9 +72,11 @@ const labelRecipe = cva({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const OpenFeedback = ({
|
const OpenFeedback = ({
|
||||||
|
posthog,
|
||||||
onNext,
|
onNext,
|
||||||
metadata,
|
metadata,
|
||||||
}: {
|
}: {
|
||||||
|
posthog: PostHog
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
}) => {
|
}) => {
|
||||||
@@ -86,7 +90,7 @@ const OpenFeedback = ({
|
|||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
try {
|
try {
|
||||||
captureEvent('open-feedback', {
|
posthog.capture('open-feedback', {
|
||||||
feedback,
|
feedback,
|
||||||
...metadata,
|
...metadata,
|
||||||
})
|
})
|
||||||
@@ -137,10 +141,12 @@ const OpenFeedback = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RateQuality = ({
|
const RateQuality = ({
|
||||||
|
posthog,
|
||||||
onNext,
|
onNext,
|
||||||
metadata,
|
metadata,
|
||||||
maxRating = 5,
|
maxRating = 5,
|
||||||
}: {
|
}: {
|
||||||
|
posthog: PostHog
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
maxRating?: number
|
maxRating?: number
|
||||||
@@ -154,7 +160,7 @@ const RateQuality = ({
|
|||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
try {
|
try {
|
||||||
captureEvent('quality-rating', {
|
posthog.capture('quality-rating', {
|
||||||
rating: selectedRating,
|
rating: selectedRating,
|
||||||
...metadata,
|
...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 = {
|
type RatingMetadata = {
|
||||||
room_id?: string
|
room_id?: string
|
||||||
|
pc_publisher?: CandidateInfo
|
||||||
|
pc_subscriber?: CandidateInfo
|
||||||
|
pc_publisher_changes_count?: number
|
||||||
|
pc_subscriber_changes_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Rating = ({
|
export const Rating = ({
|
||||||
@@ -247,6 +318,12 @@ export const Rating = ({
|
|||||||
metadata: RatingMetadata
|
metadata: RatingMetadata
|
||||||
}) => {
|
}) => {
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
|
const posthog = usePostHog()
|
||||||
|
|
||||||
|
const isUserAnonymous = useMemo(() => {
|
||||||
|
return posthog.get_property('$user_state') == 'anonymous'
|
||||||
|
}, [posthog])
|
||||||
|
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState(0)
|
||||||
|
|
||||||
const sessionId = useMemo(() => crypto.randomUUID(), [])
|
const sessionId = useMemo(() => crypto.randomUUID(), [])
|
||||||
@@ -262,14 +339,37 @@ export const Rating = ({
|
|||||||
if (!isAnalyticsEnabled) return
|
if (!isAnalyticsEnabled) return
|
||||||
|
|
||||||
if (step == 0) {
|
if (step == 0) {
|
||||||
return <RateQuality onNext={() => setStep(step + 1)} metadata={metadata} />
|
return (
|
||||||
|
<RateQuality
|
||||||
|
posthog={posthog}
|
||||||
|
onNext={() => setStep(step + 1)}
|
||||||
|
metadata={metadata}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step == 1) {
|
if (step == 1) {
|
||||||
return <OpenFeedback onNext={() => setStep(step + 1)} metadata={metadata} />
|
return (
|
||||||
|
<OpenFeedback
|
||||||
|
posthog={posthog}
|
||||||
|
onNext={() => setStep(step + 1)}
|
||||||
|
metadata={metadata}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step == 2) {
|
if (step == 2) {
|
||||||
|
return isUserAnonymous ? (
|
||||||
|
<AuthenticationMessage
|
||||||
|
posthog={posthog}
|
||||||
|
onNext={() => setStep(step + 1)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ConfirmationMessage onNext={() => setStep(0)} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step == 3) {
|
||||||
return <ConfirmationMessage onNext={() => setStep(0)} />
|
return <ConfirmationMessage onNext={() => setStep(0)} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 = () => {
|
export const useWatchPermissions = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sync = () => void syncPermissions()
|
let cleanup: (() => void) | undefined
|
||||||
sync()
|
let intervalId: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
navigator.mediaDevices?.addEventListener?.('devicechange', sync)
|
const checkPermissions = async () => {
|
||||||
window.addEventListener('focus', sync)
|
try {
|
||||||
|
if (!navigator.permissions) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
permissionsStore.cameraPermission = 'unavailable'
|
||||||
|
permissionsStore.microphonePermission = 'unavailable'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let statuses: PermissionStatus[] = []
|
const [cameraPermission, microphonePermission] = await Promise.all([
|
||||||
let cancelled = false
|
navigator.permissions.query({ name: 'camera' }),
|
||||||
if (navigator.permissions) {
|
navigator.permissions.query({ name: 'microphone' }),
|
||||||
Promise.all([
|
])
|
||||||
navigator.permissions.query({ name: 'camera' as PermissionName }),
|
|
||||||
navigator.permissions.query({ name: 'microphone' as PermissionName }),
|
if (isCancelled) return
|
||||||
])
|
|
||||||
.then((results) => {
|
/**
|
||||||
if (cancelled) return
|
* Safari Permission API Limitation Workaround
|
||||||
statuses = results
|
*
|
||||||
statuses.forEach((s) => s.addEventListener('change', sync))
|
* 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:
|
||||||
.catch(() => {
|
* https://developer.apple.com/forums/thread/757353
|
||||||
// Query unsupported: devicechange/focus + gUM outcomes cover it.
|
*
|
||||||
})
|
* The problem:
|
||||||
|
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
|
||||||
|
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
|
||||||
|
* - This leaves the UI in an inconsistent state showing outdated permission status
|
||||||
|
*
|
||||||
|
* The solution:
|
||||||
|
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
|
||||||
|
* - Continue polling until both permissions are no longer in 'prompt' state
|
||||||
|
* - This ensures we catch permission changes even when Safari fails to fire events
|
||||||
|
*
|
||||||
|
* This polling is Safari-specific and only activates when needed to minimize performance impact.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
isSafari() &&
|
||||||
|
(cameraPermission.state === 'prompt' ||
|
||||||
|
microphonePermission.state === 'prompt')
|
||||||
|
) {
|
||||||
|
// Start polling every 1 second if either permission is in 'prompt' state
|
||||||
|
if (!intervalId) {
|
||||||
|
intervalId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const [updatedCamera, updatedMicrophone] = await Promise.all([
|
||||||
|
navigator.permissions.query({ name: 'camera' }),
|
||||||
|
navigator.permissions.query({ name: 'microphone' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (isCancelled) return
|
||||||
|
|
||||||
|
const cameraChanged =
|
||||||
|
permissionsStore.cameraPermission !== updatedCamera.state
|
||||||
|
const microphoneChanged =
|
||||||
|
permissionsStore.microphonePermission !==
|
||||||
|
updatedMicrophone.state
|
||||||
|
|
||||||
|
if (cameraChanged) {
|
||||||
|
permissionsStore.cameraPermission = updatedCamera.state
|
||||||
|
}
|
||||||
|
|
||||||
|
if (microphoneChanged) {
|
||||||
|
permissionsStore.microphonePermission =
|
||||||
|
updatedMicrophone.state
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
updatedCamera.state !== 'prompt' &&
|
||||||
|
updatedMicrophone.state !== 'prompt'
|
||||||
|
) {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
console.error('Error polling permissions:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, POLLING_TIME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionsStore.cameraPermission = cameraPermission.state
|
||||||
|
permissionsStore.microphonePermission = microphonePermission.state
|
||||||
|
|
||||||
|
const handleCameraChange = (e: Event) => {
|
||||||
|
const target = e.target as PermissionStatus
|
||||||
|
permissionsStore.cameraPermission = target.state
|
||||||
|
|
||||||
|
if (
|
||||||
|
intervalId &&
|
||||||
|
target.state !== 'prompt' &&
|
||||||
|
microphonePermission.state !== 'prompt'
|
||||||
|
) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMicrophoneChange = (e: Event) => {
|
||||||
|
const target = e.target as PermissionStatus
|
||||||
|
permissionsStore.microphonePermission = target.state
|
||||||
|
|
||||||
|
if (
|
||||||
|
intervalId &&
|
||||||
|
target.state !== 'prompt' &&
|
||||||
|
microphonePermission.state !== 'prompt'
|
||||||
|
) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraPermission.addEventListener('change', handleCameraChange)
|
||||||
|
microphonePermission.addEventListener('change', handleMicrophoneChange)
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
cameraPermission.removeEventListener('change', handleCameraChange)
|
||||||
|
microphonePermission.removeEventListener(
|
||||||
|
'change',
|
||||||
|
handleMicrophoneChange
|
||||||
|
)
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
console.error('Error checking permissions:', error)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) {
|
||||||
|
permissionsStore.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
checkPermissions()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
isCancelled = true
|
||||||
navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
|
cleanup?.()
|
||||||
window.removeEventListener('focus', sync)
|
|
||||||
statuses.forEach((s) => s.removeEventListener('change', sync))
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { closeSidePanel } from '@/stores/layout'
|
import { closeSidePanel } from '@/stores/layout'
|
||||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const Admin = () => {
|
export const Admin = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||||
@@ -50,7 +49,12 @@ export const Admin = () => {
|
|||||||
isScreenShareEnabled,
|
isScreenShareEnabled,
|
||||||
} = usePublishSourcesManager()
|
} = usePublishSourcesManager()
|
||||||
|
|
||||||
const { toggleMuting, isMutingEnabled } = usePermissionsManager()
|
const {
|
||||||
|
toggleMuting,
|
||||||
|
isMutingEnabled,
|
||||||
|
isAuthenticatedCanRecordEnabled,
|
||||||
|
toggleAuthenticatedCanRecord,
|
||||||
|
} = usePermissionsManager()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Div
|
<Div
|
||||||
@@ -155,6 +159,17 @@ export const Admin = () => {
|
|||||||
fullWidth: true,
|
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>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -206,7 +221,7 @@ export const Admin = () => {
|
|||||||
patchRoom({
|
patchRoom({
|
||||||
roomId,
|
roomId,
|
||||||
room: { access_level: value as ApiAccessLevel },
|
room: { access_level: value as ApiAccessLevel },
|
||||||
}).catch((e) => reportError('generic_failure', e))
|
}).catch((e) => console.error(e))
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,16 +9,28 @@ import { useSnapshot } from 'valtio'
|
|||||||
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||||
|
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
|
|
||||||
import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
|
|
||||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||||
|
|
||||||
|
import posthog from 'posthog-js'
|
||||||
|
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||||
|
import { isMobileBrowser } from '@livekit/components-core'
|
||||||
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
|
|
||||||
|
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
||||||
|
|
||||||
export const ConnectionObserver = () => {
|
export const ConnectionObserver = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const connectionStartTimeRef = useRef<number | null>(null)
|
const connectionStartTimeRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
|
|
||||||
|
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
||||||
|
const isMobile = isMobileBrowser()
|
||||||
|
|
||||||
|
const isAdvancedConnectionObserverEnabled =
|
||||||
|
!isMobile && isAnalyticsEnabled && featureEnabled
|
||||||
|
|
||||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||||
|
|
||||||
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
|
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
|
||||||
@@ -68,6 +80,100 @@ export const ConnectionObserver = () => {
|
|||||||
userPreferencesSnap.is_idle_disconnect_modal_enabled,
|
userPreferencesSnap.is_idle_disconnect_modal_enabled,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdvancedConnectionObserverEnabled) return
|
||||||
|
if (!room) return
|
||||||
|
|
||||||
|
let interval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const pollCandidate = async (
|
||||||
|
label: 'publisher' | 'subscriber',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
pc?: any
|
||||||
|
) => {
|
||||||
|
if (!pc) return
|
||||||
|
|
||||||
|
let stats: RTCStatsReport
|
||||||
|
try {
|
||||||
|
stats = await pc.getStats()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
stats.forEach((report: any) => {
|
||||||
|
if (
|
||||||
|
report.type === 'candidate-pair' &&
|
||||||
|
report.state === 'succeeded' &&
|
||||||
|
report.nominated
|
||||||
|
) {
|
||||||
|
const remoteCandidate = stats.get(report.remoteCandidateId)
|
||||||
|
if (!remoteCandidate) return
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
type: remoteCandidate.candidateType,
|
||||||
|
address: remoteCandidate.address,
|
||||||
|
protocol: remoteCandidate.protocol,
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = connectionObserverStore[label]
|
||||||
|
|
||||||
|
const hasChanged =
|
||||||
|
current?.type !== next.type ||
|
||||||
|
current?.address !== next.address ||
|
||||||
|
current?.protocol !== next.protocol
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
connectionObserverStore[label] = next
|
||||||
|
const key = `${label}ChangesCount` as const
|
||||||
|
connectionObserverStore[key] =
|
||||||
|
(connectionObserverStore[key] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
const publisher = room.engine?.pcManager?.publisher
|
||||||
|
const subscriber = room.engine?.pcManager?.subscriber
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
pollCandidate('publisher', publisher),
|
||||||
|
pollCandidate('subscriber', subscriber),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPolling = async () => {
|
||||||
|
if (interval) return // prevent duplicates
|
||||||
|
|
||||||
|
// Initial snapshot
|
||||||
|
await poll()
|
||||||
|
|
||||||
|
interval = setInterval(poll, CANDIDATE_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (!interval) return
|
||||||
|
clearInterval(interval)
|
||||||
|
interval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
room.on(RoomEvent.Connected, startPolling)
|
||||||
|
room.on(RoomEvent.Reconnected, startPolling)
|
||||||
|
|
||||||
|
room.on(RoomEvent.Reconnecting, stopPolling)
|
||||||
|
room.on(RoomEvent.Disconnected, stopPolling)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopPolling()
|
||||||
|
|
||||||
|
room.off(RoomEvent.Connected, startPolling)
|
||||||
|
room.off(RoomEvent.Reconnected, startPolling)
|
||||||
|
room.off(RoomEvent.Reconnecting, stopPolling)
|
||||||
|
room.off(RoomEvent.Disconnected, stopPolling)
|
||||||
|
}
|
||||||
|
}, [room, isAdvancedConnectionObserverEnabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAnalyticsEnabled) return
|
if (!isAnalyticsEnabled) return
|
||||||
|
|
||||||
@@ -76,23 +182,23 @@ export const ConnectionObserver = () => {
|
|||||||
// total session duration from first connect to final disconnect.
|
// total session duration from first connect to final disconnect.
|
||||||
if (connectionStartTimeRef.current != null) return
|
if (connectionStartTimeRef.current != null) return
|
||||||
connectionStartTimeRef.current = Date.now()
|
connectionStartTimeRef.current = Date.now()
|
||||||
void captureMediaEvent('connection-event', {})
|
posthog.capture('connection-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReconnect = () => {
|
const handleReconnect = () => {
|
||||||
captureEvent('reconnect-event')
|
posthog.capture('reconnect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReconnected = () => {
|
const handleReconnected = () => {
|
||||||
captureEvent('reconnected-event')
|
posthog.capture('reconnected-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSignalingConnect = () => {
|
const handleSignalingConnect = () => {
|
||||||
captureEvent('signaling-connect-event')
|
posthog.capture('signaling-connect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSignalingReconnect = () => {
|
const handleSignalingReconnect = () => {
|
||||||
captureEvent('signaling-reconnect-event')
|
posthog.capture('signaling-reconnect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDisconnect = (
|
const handleDisconnect = (
|
||||||
@@ -100,7 +206,7 @@ export const ConnectionObserver = () => {
|
|||||||
) => {
|
) => {
|
||||||
const connectionEndTime = Date.now()
|
const connectionEndTime = Date.now()
|
||||||
|
|
||||||
captureEvent('disconnect-event', {
|
posthog.capture('disconnect-event', {
|
||||||
// Calculate total session duration from first connection to final disconnect
|
// Calculate total session duration from first connection to final disconnect
|
||||||
// This duration is sensitive to refreshing the page.
|
// This duration is sensitive to refreshing the page.
|
||||||
sessionDuration: connectionStartTimeRef.current
|
sessionDuration: connectionStartTimeRef.current
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const Info = () => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||||
|
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
const roomUrl = data?.slug ? getRouteUrl('room', data.slug) : ''
|
const roomUrl = getRouteUrl('room', data?.slug)
|
||||||
|
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { A, Button, Dialog, P } from '@/primitives'
|
import { A, Button, Dialog, P } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { css } from '@/styled-system/css'
|
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
|
// todo - refactor it into a generic system
|
||||||
export const ScreenShareErrorModal = ({
|
export const ScreenShareErrorModal = ({
|
||||||
@@ -18,8 +11,7 @@ export const ScreenShareErrorModal = ({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
||||||
const os = getOS()
|
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
|
||||||
const settingsHref = SCREEN_CAPTURE_SETTINGS_LINKS[os]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -34,16 +26,15 @@ export const ScreenShareErrorModal = ({
|
|||||||
<>
|
<>
|
||||||
<P>
|
<P>
|
||||||
{t('message')}{' '}
|
{t('message')}{' '}
|
||||||
{settingsHref && (
|
{isMac && (
|
||||||
<>
|
<>
|
||||||
{t('settingsInstructions')}{' '}
|
{t('macInstructions')}{' '}
|
||||||
<A
|
<A
|
||||||
href={settingsHref}
|
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
||||||
target="_blank"
|
|
||||||
color="primary"
|
color="primary"
|
||||||
aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
|
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
|
||||||
>
|
>
|
||||||
{t(`settingsLabel.${os}`)}
|
{t('macSystemPreferences')}
|
||||||
</A>
|
</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
|
|
||||||
}
|
|
||||||
+7
-5
@@ -1,4 +1,5 @@
|
|||||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||||
|
import posthog from 'posthog-js'
|
||||||
import {
|
import {
|
||||||
FilesetResolver,
|
FilesetResolver,
|
||||||
ImageSegmenter,
|
ImageSegmenter,
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
type ProcessorType,
|
type ProcessorType,
|
||||||
MEDIAPIPE_PATH_WASM,
|
MEDIAPIPE_PATH_WASM,
|
||||||
} from '.'
|
} from '.'
|
||||||
import { captureEvent } from '@/features/analytics/telemetry.ts'
|
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256
|
const PROCESSING_WIDTH = 256
|
||||||
const PROCESSING_HEIGHT = 144
|
const PROCESSING_HEIGHT = 144
|
||||||
@@ -44,7 +44,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
videoElement?: HTMLVideoElement
|
videoElement?: HTMLVideoElement
|
||||||
videoElementLoaded?: boolean
|
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
|
outputCanvas?: HTMLCanvasElement
|
||||||
outputCanvasCtx?: CanvasRenderingContext2D
|
outputCanvasCtx?: CanvasRenderingContext2D
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
segmentationMaskCanvas?: HTMLCanvasElement
|
segmentationMaskCanvas?: HTMLCanvasElement
|
||||||
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
||||||
|
|
||||||
// Mask containing the inference result.
|
// Mask containg the inference result.
|
||||||
segmentationMask?: ImageData
|
segmentationMask?: ImageData
|
||||||
|
|
||||||
// The resized image of the video source.
|
// The resized image of the video source.
|
||||||
@@ -100,12 +100,14 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
await this.initSegmenter()
|
await this.initSegmenter()
|
||||||
this._initWorker()
|
this._initWorker()
|
||||||
|
|
||||||
captureEvent('firefox-blurring-init', {})
|
posthog.capture('firefox-blurring-init')
|
||||||
}
|
}
|
||||||
|
|
||||||
_initVirtualBackgroundImage() {
|
_initVirtualBackgroundImage() {
|
||||||
if (this.options.type !== 'virtual') {
|
if (this.options.type !== 'virtual') {
|
||||||
return
|
throw new Error(
|
||||||
|
'Virtual background is only supported for virtual background'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsUpdate =
|
const needsUpdate =
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
|
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
|
||||||
|
import posthog from 'posthog-js'
|
||||||
import {
|
import {
|
||||||
FilesetResolver,
|
FilesetResolver,
|
||||||
FaceLandmarker,
|
FaceLandmarker,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
ProcessorType,
|
ProcessorType,
|
||||||
MEDIAPIPE_PATH_WASM,
|
MEDIAPIPE_PATH_WASM,
|
||||||
} from '.'
|
} from '.'
|
||||||
import { captureEvent } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256 * 3
|
const PROCESSING_WIDTH = 256 * 3
|
||||||
const PROCESSING_HEIGHT = 144 * 3
|
const PROCESSING_HEIGHT = 144 * 3
|
||||||
@@ -101,7 +101,7 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
|||||||
await this.initFaceLandmarker()
|
await this.initFaceLandmarker()
|
||||||
this._initWorker()
|
this._initWorker()
|
||||||
|
|
||||||
captureEvent('face-landmarks-init', {})
|
posthog.capture('face-landmarks-init')
|
||||||
}
|
}
|
||||||
|
|
||||||
_initWorker() {
|
_initWorker() {
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { ProcessorWrapper } from '@livekit/track-processors'
|
||||||
ProcessorWrapper,
|
|
||||||
supportsBackgroundProcessors,
|
|
||||||
} from '@livekit/track-processors'
|
|
||||||
import type { Track, TrackProcessor } from 'livekit-client'
|
import type { Track, TrackProcessor } from 'livekit-client'
|
||||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||||
@@ -13,7 +10,7 @@ export const SELFIE_SEGMENTER_MODEL_PATH =
|
|||||||
export const FACE_LANDMARKS_MODEL_PATH =
|
export const FACE_LANDMARKS_MODEL_PATH =
|
||||||
'/assets/mediapipe/models/face_landmarker.task'
|
'/assets/mediapipe/models/face_landmarker.task'
|
||||||
|
|
||||||
export const MEDIAPIPE_PATH_WASM = `/assets/mediapipe/wasm/${__MEDIAPIPE_VERSION__}`
|
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
|
||||||
|
|
||||||
export enum ProcessorType {
|
export enum ProcessorType {
|
||||||
BLUR = 'blur',
|
BLUR = 'blur',
|
||||||
@@ -37,9 +34,7 @@ export class BackgroundProcessorFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static isSupported() {
|
static isSupported() {
|
||||||
return (
|
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
|
||||||
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static getProcessor(
|
static getProcessor(
|
||||||
@@ -50,7 +45,7 @@ export class BackgroundProcessorFactory {
|
|||||||
|
|
||||||
if (!isBlur && !isVirtual) return undefined
|
if (!isBlur && !isVirtual) return undefined
|
||||||
|
|
||||||
if (supportsBackgroundProcessors()) {
|
if (ProcessorWrapper.isSupported) {
|
||||||
return new UnifiedBackgroundTrackProcessor(config)
|
return new UnifiedBackgroundTrackProcessor(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { RiCameraSwitchLine } from '@remixicon/react'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { ButtonProps } from 'react-aria-components'
|
import type { ButtonProps } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
enum FacingMode {
|
enum FacingMode {
|
||||||
USER = 'user',
|
USER = 'user',
|
||||||
@@ -104,11 +103,7 @@ export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
|
|||||||
setActiveMediaDevice(device.deviceId)
|
setActiveMediaDevice(device.deviceId)
|
||||||
setFacingMode(target)
|
setFacingMode(target)
|
||||||
} else {
|
} else {
|
||||||
reportError(
|
console.error('Cannot get user device with facingMode ' + target)
|
||||||
'device_switch_failure',
|
|
||||||
new Error('Cannot get user device with facingMode ' + target),
|
|
||||||
{ path: 'switch_device', kind: 'videoinput', facing_mode: target }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
+2
-13
@@ -1,12 +1,8 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||||
useLocalParticipant,
|
|
||||||
useTrackToggle,
|
|
||||||
UseTrackToggleProps,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { Button, Popover } from '@/primitives'
|
import { Button, Popover } from '@/primitives'
|
||||||
import { RiArrowUpSLine } from '@remixicon/react'
|
import { RiArrowUpSLine } from '@remixicon/react'
|
||||||
import { LocalAudioTrack, Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
|
|
||||||
import { ToggleDevice } from './ToggleDevice'
|
import { ToggleDevice } from './ToggleDevice'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
@@ -55,12 +51,6 @@ export const AudioDevicesControl = ({
|
|||||||
...props,
|
...props,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { microphoneTrack } = useLocalParticipant()
|
|
||||||
const localAudioTrack =
|
|
||||||
microphoneTrack?.track instanceof LocalAudioTrack
|
|
||||||
? microphoneTrack.track
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const kind = 'audioinput'
|
const kind = 'audioinput'
|
||||||
const cannotUseDevice = useCannotUseDevice(kind)
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
||||||
@@ -121,7 +111,6 @@ export const AudioDevicesControl = ({
|
|||||||
context="room"
|
context="room"
|
||||||
kind={kind}
|
kind={kind}
|
||||||
id={audioDeviceId}
|
id={audioDeviceId}
|
||||||
track={localAudioTrack}
|
|
||||||
onSubmit={saveAudioInputDeviceId}
|
onSubmit={saveAudioInputDeviceId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-132
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-134
@@ -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
-13
@@ -4,17 +4,8 @@ import { openPermissionsDialog } from '@/stores/permissions'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
type PermissionNeededButtonProps = {
|
export const PermissionNeededButton = () => {
|
||||||
tooltip?: string
|
|
||||||
onPress?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PermissionNeededButton = ({
|
|
||||||
tooltip,
|
|
||||||
onPress,
|
|
||||||
}: PermissionNeededButtonProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
||||||
const label = tooltip ?? t('tooltip')
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
@@ -26,9 +17,9 @@ export const PermissionNeededButton = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
aria-label={tooltip ? label : t('ariaLabel')}
|
aria-label={t('ariaLabel')}
|
||||||
tooltip={label}
|
tooltip={t('tooltip')}
|
||||||
onPress={onPress ?? (() => openPermissionsDialog())}
|
onPress={() => openPermissionsDialog()}
|
||||||
variant="permission"
|
variant="permission"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import { useEffect, useMemo } from 'react'
|
|||||||
import { Select, SelectProps } from '@/primitives/Select'
|
import { Select, SelectProps } from '@/primitives/Select'
|
||||||
import type { Placement } from '@react-types/overlays'
|
import type { Placement } from '@react-types/overlays'
|
||||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
|
||||||
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
|
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 }>
|
type DeviceItems = Array<{ value: string; label: string }>
|
||||||
|
|
||||||
@@ -23,7 +18,6 @@ type SelectDeviceProps = {
|
|||||||
onSubmit?: (id: string) => void
|
onSubmit?: (id: string) => void
|
||||||
kind: MediaDeviceKind
|
kind: MediaDeviceKind
|
||||||
context?: 'join' | 'room'
|
context?: 'join' | 'room'
|
||||||
track?: LocalAudioTrack
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
|
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
|
||||||
@@ -34,7 +28,6 @@ const SelectDevicePermissions = <T extends string | number>({
|
|||||||
kind,
|
kind,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
iconComponent,
|
iconComponent,
|
||||||
track,
|
|
||||||
...props
|
...props
|
||||||
}: SelectDevicePermissionsProps<T>) => {
|
}: SelectDevicePermissionsProps<T>) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
@@ -81,16 +74,6 @@ const SelectDevicePermissions = <T extends string | number>({
|
|||||||
await setActiveMediaDevice(key as string)
|
await setActiveMediaDevice(key as string)
|
||||||
onSubmit?.(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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -101,7 +84,6 @@ export const SelectDevice = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
kind,
|
kind,
|
||||||
context = 'join',
|
context = 'join',
|
||||||
track,
|
|
||||||
}: SelectDeviceProps) => {
|
}: SelectDeviceProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
|
||||||
@@ -114,25 +96,6 @@ export const SelectDevice = ({
|
|||||||
|
|
||||||
const deviceIcons = useDeviceIcons(kind)
|
const deviceIcons = useDeviceIcons(kind)
|
||||||
const cannotUseDevice = useCannotUseDevice(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) {
|
if (cannotUseDevice) {
|
||||||
return (
|
return (
|
||||||
@@ -153,7 +116,6 @@ export const SelectDevice = ({
|
|||||||
id={id}
|
id={id}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
track={track}
|
|
||||||
iconComponent={deviceIcons.select}
|
iconComponent={deviceIcons.select}
|
||||||
{...contextProps}
|
{...contextProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+11
-68
@@ -1,7 +1,7 @@
|
|||||||
import { ToggleButton } from '@/primitives'
|
import { ToggleButton } from '@/primitives'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { useMemo, useRef, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { PermissionNeededButton } from './PermissionNeededButton'
|
import { PermissionNeededButton } from './PermissionNeededButton'
|
||||||
@@ -12,16 +12,10 @@ import {
|
|||||||
useMaybeRoomContext,
|
useMaybeRoomContext,
|
||||||
useRoomContext,
|
useRoomContext,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { MediaDeviceFailure } from 'livekit-client'
|
|
||||||
import { MediaDeviceErrorAlert } from '@/features/rooms/components/MediaDeviceErrorAlert'
|
|
||||||
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||||
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
import { openPermissionsDialog } from '@/stores/permissions'
|
import { openPermissionsDialog } from '@/stores/permissions'
|
||||||
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
|
||||||
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
|
||||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||||
import type {
|
import type {
|
||||||
@@ -96,45 +90,9 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
|
|
||||||
const deviceIcons = useDeviceIcons(kind)
|
const deviceIcons = useDeviceIcons(kind)
|
||||||
const cannotUseDevice = useCannotUseDevice(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 deviceShortcut = useDeviceShortcut(kind)
|
||||||
const announce = useScreenReaderAnnounce()
|
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({
|
useRegisterKeyboardShortcut({
|
||||||
id: deviceShortcut?.id,
|
id: deviceShortcut?.id,
|
||||||
handler: async () => {
|
handler: async () => {
|
||||||
@@ -181,20 +139,7 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
{(cannotUseDevice || deviceMissing) && (
|
{cannotUseDevice && <PermissionNeededButton />}
|
||||||
<PermissionNeededButton
|
|
||||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
|
||||||
onPress={
|
|
||||||
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{silentMicWarning && (
|
|
||||||
<PermissionNeededButton
|
|
||||||
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
|
|
||||||
onPress={openSilentMicDialog}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
isSelected={!enabled}
|
isSelected={!enabled}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
@@ -202,25 +147,23 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
isDisabled || cannotUseDevice || !enabled ? errorVariant : variant
|
isDisabled || cannotUseDevice || !enabled ? errorVariant : variant
|
||||||
}
|
}
|
||||||
shySelected
|
shySelected
|
||||||
onPress={onPress}
|
onPress={() => {
|
||||||
|
if (cannotUseDevice) {
|
||||||
|
openPermissionsDialog(kind)
|
||||||
|
}
|
||||||
|
toggle()
|
||||||
|
}}
|
||||||
aria-label={toggleLabel}
|
aria-label={toggleLabel}
|
||||||
tooltip={
|
tooltip={
|
||||||
deviceMissing
|
cannotUseDevice
|
||||||
? t(`deviceNotFound.${kind}`)
|
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||||
: cannotUseDevice
|
: toggleLabel
|
||||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
|
||||||
: toggleLabel
|
|
||||||
}
|
}
|
||||||
{...computedToggleButtonProps}
|
{...computedToggleButtonProps}
|
||||||
{...overrideToggleButtonProps}
|
{...overrideToggleButtonProps}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<MediaDeviceErrorAlert
|
|
||||||
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
|
|
||||||
kind={kind}
|
|
||||||
onClose={() => setShowDeviceNotFound(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Button } from '@/primitives'
|
|||||||
import { RiPhoneFill } from '@remixicon/react'
|
import { RiPhoneFill } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ConnectionState } from 'livekit-client'
|
import { ConnectionState } from 'livekit-client'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const LeaveButton = () => {
|
export const LeaveButton = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
||||||
@@ -16,11 +15,11 @@ export const LeaveButton = () => {
|
|||||||
tooltip={t('leave')}
|
tooltip={t('leave')}
|
||||||
aria-label={t('leave')}
|
aria-label={t('leave')}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
room.disconnect(true).catch((e) =>
|
room
|
||||||
reportError('disconnect_failure', e, {
|
.disconnect(true)
|
||||||
context: 'An error occurred while disconnecting:',
|
.catch((e) =>
|
||||||
})
|
console.error('An error occurred while disconnecting:', e)
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
data-attr="controls-leave"
|
data-attr="controls-leave"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import { useConfig } from '@/api/useConfig.ts'
|
|||||||
import { proxy, useSnapshot } from 'valtio'
|
import { proxy, useSnapshot } from 'valtio'
|
||||||
import { Spinner } from '@/primitives/Spinner.tsx'
|
import { Spinner } from '@/primitives/Spinner.tsx'
|
||||||
import { userChoicesStore, saveProcessorConfig } from '@/stores/userChoices'
|
import { userChoicesStore, saveProcessorConfig } from '@/stores/userChoices'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
enum BlurRadius {
|
enum BlurRadius {
|
||||||
NONE = 0,
|
NONE = 0,
|
||||||
@@ -239,9 +238,7 @@ export const EffectsConfiguration = ({
|
|||||||
|
|
||||||
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
|
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('effects_processor_failure', error, {
|
console.error('Error applying effect:', error)
|
||||||
context: 'Error applying effect:',
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
// Without setTimeout the DOM is not refreshing when updating the options.
|
// Without setTimeout the DOM is not refreshing when updating the options.
|
||||||
setTimeout(() => setProcessorPending(false))
|
setTimeout(() => setProcessorPending(false))
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { RiGlassesLine, RiGoblet2Fill } from '@remixicon/react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
|
import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
|
||||||
import type { LocalVideoTrack } from 'livekit-client'
|
import type { LocalVideoTrack } from 'livekit-client'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export type FunnyEffectsProps = {
|
export type FunnyEffectsProps = {
|
||||||
videoTrack: LocalVideoTrack
|
videoTrack: LocalVideoTrack
|
||||||
@@ -56,9 +55,7 @@ export const FunnyEffects = ({
|
|||||||
await videoTrack.setProcessor(newProcessor)
|
await videoTrack.setProcessor(newProcessor)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reportError('effects_processor_failure', e, {
|
console.error('could not update processor', e)
|
||||||
context: 'could not update processor',
|
|
||||||
})
|
|
||||||
} finally {
|
} finally {
|
||||||
onPending(false)
|
onPending(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
|||||||
isMicrophonePrompted,
|
isMicrophonePrompted,
|
||||||
isCameraDenied,
|
isCameraDenied,
|
||||||
isCameraPrompted,
|
isCameraPrompted,
|
||||||
microphoneSystemDenied,
|
|
||||||
cameraSystemDenied,
|
|
||||||
} = useSnapshot(permissionsStore)
|
} = useSnapshot(permissionsStore)
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
@@ -19,11 +17,9 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
|||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'audioinput':
|
case 'audioinput':
|
||||||
case 'audiooutput': // audiooutput uses microphone permissions
|
case 'audiooutput': // audiooutput uses microphone permissions
|
||||||
return (
|
return isMicrophoneDenied || isMicrophonePrompted
|
||||||
isMicrophoneDenied || isMicrophonePrompted || microphoneSystemDenied
|
|
||||||
)
|
|
||||||
case 'videoinput':
|
case 'videoinput':
|
||||||
return isCameraDenied || isCameraPrompted || cameraSystemDenied
|
return isCameraDenied || isCameraPrompted
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -35,7 +31,5 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
|||||||
isMicrophonePrompted,
|
isMicrophonePrompted,
|
||||||
isCameraDenied,
|
isCameraDenied,
|
||||||
isCameraPrompted,
|
isCameraPrompted,
|
||||||
microphoneSystemDenied,
|
|
||||||
cameraSystemDenied,
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||||
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
const COPY_SUCCESS_TIMEOUT = 3000
|
const COPY_SUCCESS_TIMEOUT = 3000
|
||||||
|
|
||||||
@@ -59,9 +58,7 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
|||||||
await navigator.clipboard.writeText(content)
|
await navigator.clipboard.writeText(content)
|
||||||
setIsCopied(true)
|
setIsCopied(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('clipboard_failure', error, {
|
console.error(error)
|
||||||
context: 'copy_room_content',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,9 +67,7 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
|||||||
await navigator.clipboard.writeText(roomUrl)
|
await navigator.clipboard.writeText(roomUrl)
|
||||||
setIsRoomUrlCopied(true)
|
setIsRoomUrlCopied(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('clipboard_failure', error, {
|
console.error(error)
|
||||||
context: 'copy_room_url',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useMemo, useState } from 'react'
|
||||||
import { type TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import { type TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export function useFullScreen({
|
export function useFullScreen({
|
||||||
trackRef,
|
trackRef,
|
||||||
@@ -57,9 +56,7 @@ export function useFullScreen({
|
|||||||
await docEl.msRequestFullscreen()
|
await docEl.msRequestFullscreen()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('fullscreen_failure', error, {
|
console.error('Error entering fullscreen:', error)
|
||||||
context: 'Error entering fullscreen:',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +70,7 @@ export function useFullScreen({
|
|||||||
await document.msExitFullscreen()
|
await document.msExitFullscreen()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('fullscreen_failure', error, {
|
console.error('Error exiting fullscreen:', error)
|
||||||
context: 'Error exiting fullscreen:',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const usePermissionsManager = () => {
|
export const usePermissionsManager = () => {
|
||||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||||
@@ -11,6 +10,8 @@ export const usePermissionsManager = () => {
|
|||||||
const roomId = data?.slug
|
const roomId = data?.slug
|
||||||
|
|
||||||
const isMutingEnabled = configuration?.everyone_can_mute ?? true
|
const isMutingEnabled = configuration?.everyone_can_mute ?? true
|
||||||
|
const isAuthenticatedCanRecordEnabled =
|
||||||
|
configuration?.authenticated_can_record ?? true
|
||||||
|
|
||||||
const toggleMuting = useCallback(
|
const toggleMuting = useCallback(
|
||||||
async (enabled: boolean) => {
|
async (enabled: boolean) => {
|
||||||
@@ -29,9 +30,33 @@ export const usePermissionsManager = () => {
|
|||||||
|
|
||||||
return { configuration: newConfiguration }
|
return { configuration: newConfiguration }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('permissions_api_failure', error, {
|
console.error('Failed to update muting permission:', error)
|
||||||
context: 'Failed to update muting permission:',
|
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 }
|
return { success: false, error }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -41,5 +66,7 @@ export const usePermissionsManager = () => {
|
|||||||
return {
|
return {
|
||||||
toggleMuting,
|
toggleMuting,
|
||||||
isMutingEnabled,
|
isMutingEnabled,
|
||||||
|
isAuthenticatedCanRecordEnabled,
|
||||||
|
toggleAuthenticatedCanRecord,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
NotificationType,
|
NotificationType,
|
||||||
useNotifyParticipants,
|
useNotifyParticipants,
|
||||||
} from '@/features/notifications'
|
} from '@/features/notifications'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const updatePublishSources = (
|
export const updatePublishSources = (
|
||||||
currentSources: Source[],
|
currentSources: Source[],
|
||||||
@@ -109,9 +108,7 @@ export const usePublishSourcesManager = () => {
|
|||||||
|
|
||||||
return { configuration: newConfiguration }
|
return { configuration: newConfiguration }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('publish_sources_failure', error, {
|
console.error(`Failed to update ${sources}:`, error)
|
||||||
context: `Failed to update ${sources}:`,
|
|
||||||
})
|
|
||||||
return { success: false, error }
|
return { success: false, error }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import { isLocal } from '@/utils/livekit'
|
import { isLocal } from '@/utils/livekit'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useRaiseHand } from '@/features/rooms/api/updateRaiseHand'
|
import { useRaiseHand } from '@/features/rooms/api/updateRaiseHand'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
type useRaisedHandProps = {
|
type useRaisedHandProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
@@ -80,9 +79,9 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
|
|||||||
try {
|
try {
|
||||||
await raiseHand(!isHandRaised)
|
await raiseHand(!isHandRaised)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reportError('generic_failure', e, {
|
console.error(
|
||||||
context: 'toggle_raised_hand',
|
`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 { useTitle } from 'hoofd'
|
||||||
|
import { useRoomData } from './useRoomData'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
const APP_TITLE = import.meta.env.VITE_APP_TITLE ?? ''
|
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
|
* the meeting tab among many open tabs. Works on both the join screen and
|
||||||
* once connected.
|
* once connected.
|
||||||
*/
|
*/
|
||||||
export const useRoomPageTitle = (roomId?: string) => {
|
export const useRoomPageTitle = () => {
|
||||||
|
const roomData = useRoomData()
|
||||||
|
|
||||||
const pageTitle = useMemo(() => {
|
const pageTitle = useMemo(() => {
|
||||||
if (!roomId) return APP_TITLE
|
if (!roomData) {
|
||||||
return `${APP_TITLE} - ${roomId}`
|
return APP_TITLE
|
||||||
}, [roomId])
|
}
|
||||||
|
|
||||||
|
const roomLabel = roomData.name || roomData.slug || ''
|
||||||
|
|
||||||
|
if (!roomLabel) return APP_TITLE
|
||||||
|
|
||||||
|
return `${APP_TITLE} - ${roomLabel} `
|
||||||
|
}, [roomData])
|
||||||
|
|
||||||
useTitle(pageTitle)
|
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 }
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
|||||||
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||||
import { MoreOptions } from './MoreOptions'
|
import { MoreOptions } from './MoreOptions'
|
||||||
import { RefObject, useMemo, useState } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
@@ -21,14 +21,7 @@ export function DesktopControlBar({
|
|||||||
onDeviceError,
|
onDeviceError,
|
||||||
}: Readonly<ControlBarAuxProps>) {
|
}: Readonly<ControlBarAuxProps>) {
|
||||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
|
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||||
const [controlBarElement, setControlBarElement] =
|
|
||||||
useState<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
const desktopControlBarEl = useMemo<RefObject<HTMLDivElement>>(
|
|
||||||
() => ({ current: controlBarElement }),
|
|
||||||
[controlBarElement]
|
|
||||||
)
|
|
||||||
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||||
|
|
||||||
@@ -52,7 +45,7 @@ export function DesktopControlBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setControlBarElement}
|
ref={desktopControlBarEl}
|
||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
|||||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const CONTROL_BAR_BREAKPOINT_WIDE = 1100
|
const CONTROL_BAR_BREAKPOINT = 1100
|
||||||
const CONTROL_BAR_BREAKPOINT_NARROW = 1050
|
|
||||||
|
|
||||||
const NavigationControls = ({
|
const NavigationControls = ({
|
||||||
onPress,
|
onPress,
|
||||||
@@ -66,9 +65,10 @@ export const LateralMenu = () => {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BreakpointObserverProps {
|
interface BreakpointObserverProps {
|
||||||
containerRef: RefObject<HTMLDivElement>
|
containerRef: RefObject<HTMLDivElement>
|
||||||
onWideChange: (isWide: boolean | null) => void
|
onWideChange: (isWide: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const BreakpointObserver = ({
|
const BreakpointObserver = ({
|
||||||
@@ -76,20 +76,7 @@ const BreakpointObserver = ({
|
|||||||
onWideChange,
|
onWideChange,
|
||||||
}: BreakpointObserverProps) => {
|
}: BreakpointObserverProps) => {
|
||||||
const { width } = useSize(containerRef)
|
const { width } = useSize(containerRef)
|
||||||
const [isWide, setIsWide] = useState<boolean | null>(null)
|
const isWide = width > CONTROL_BAR_BREAKPOINT
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onWideChange(isWide)
|
onWideChange(isWide)
|
||||||
@@ -103,7 +90,7 @@ export const MoreOptions = ({
|
|||||||
}: {
|
}: {
|
||||||
parentElement: RefObject<HTMLDivElement>
|
parentElement: RefObject<HTMLDivElement>
|
||||||
}) => {
|
}) => {
|
||||||
const [isWide, setIsWide] = useState<boolean | null>(null)
|
const [isWide, setIsWide] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -120,7 +107,7 @@ export const MoreOptions = ({
|
|||||||
containerRef={parentElement}
|
containerRef={parentElement}
|
||||||
onWideChange={setIsWide}
|
onWideChange={setIsWide}
|
||||||
/>
|
/>
|
||||||
{isWide !== null && (isWide ? <NavigationControls /> : <LateralMenu />)}
|
{isWide ? <NavigationControls /> : <LateralMenu />}
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ import { SidePanel } from '../components/SidePanel'
|
|||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
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 { MediaStateObserver } from '../components/MediaStateObserver'
|
||||||
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||||
|
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
||||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
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 { StageLayout } from '@/features/layout/components/StageLayout'
|
||||||
import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
|
import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
|
||||||
import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
||||||
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
|
|
||||||
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
@@ -38,20 +34,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
|
|||||||
SettingsComponent?: React.ComponentType
|
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.
|
* 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
|
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
|
||||||
@@ -71,46 +53,17 @@ const getScreenSharePermissionDeniedScope = (
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||||
|
useRoomPageTitle()
|
||||||
useNoiseReduction()
|
useNoiseReduction()
|
||||||
|
|
||||||
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
||||||
|
|
||||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<RoomMetadataSynchronizer />
|
<RoomMetadataSynchronizer />
|
||||||
<ConnectionObserver />
|
<ConnectionObserver />
|
||||||
<SyncDevicePreferences />
|
|
||||||
<RoomSilentMicDetector />
|
|
||||||
<MediaStateObserver />
|
<MediaStateObserver />
|
||||||
<ChatProvider />
|
<ChatProvider />
|
||||||
<VideoResolutionSubscription />
|
<VideoResolutionSubscription />
|
||||||
@@ -136,7 +89,18 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<StageLayout />
|
<StageLayout />
|
||||||
)}
|
)}
|
||||||
</RoomContentArea>
|
</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 />
|
<SidePanel />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Rating } from '@/features/rooms/components/Rating.tsx'
|
|||||||
import { useLocation } from 'wouter'
|
import { useLocation } from 'wouter'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { DisconnectReason } from 'livekit-client'
|
import { DisconnectReason } from 'livekit-client'
|
||||||
|
import type { CandidateInfo } from '@/stores/connectionObserver'
|
||||||
|
|
||||||
// fixme - duplicated with home, refactor in a proper style
|
// fixme - duplicated with home, refactor in a proper style
|
||||||
const Heading = styled('h1', {
|
const Heading = styled('h1', {
|
||||||
@@ -47,6 +48,10 @@ const FeedbackRoute = () => {
|
|||||||
const state = window.history.state
|
const state = window.history.state
|
||||||
return {
|
return {
|
||||||
room_id: state?.room_id as string,
|
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 { Conference } from '../components/Conference'
|
||||||
import { Join } from '../components/Join'
|
import { Join } from '../components/Join'
|
||||||
import { Permissions } from '../components/Permissions'
|
import { Permissions } from '../components/Permissions'
|
||||||
import { SilentMicDialog } from '../components/SilentMicDialog'
|
|
||||||
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
|
||||||
import {
|
import {
|
||||||
isRoomValid,
|
isRoomValid,
|
||||||
@@ -15,14 +14,11 @@ import {
|
|||||||
} from '@/features/rooms/utils/isRoomValid'
|
} from '@/features/rooms/utils/isRoomValid'
|
||||||
import { useConfig } from '@/api/useConfig.ts'
|
import { useConfig } from '@/api/useConfig.ts'
|
||||||
import { LogLevel, setLogLevel } from 'livekit-client'
|
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 }) => {
|
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<UserAware>
|
<UserAware>
|
||||||
<Permissions />
|
<Permissions />
|
||||||
<SilentMicDialog />
|
|
||||||
{children}
|
{children}
|
||||||
</UserAware>
|
</UserAware>
|
||||||
)
|
)
|
||||||
@@ -40,15 +36,12 @@ const Room = () => {
|
|||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
|
|
||||||
useRoomPageTitle(roomId)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const shouldSilenceLogs = data?.silence_livekit_debug_logs || false
|
const shouldSilenceLogs = data?.silence_livekit_debug_logs || false
|
||||||
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug)
|
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug)
|
||||||
}, [data?.silence_livekit_debug_logs])
|
}, [data?.silence_livekit_debug_logs])
|
||||||
|
|
||||||
useKeyboardShortcuts()
|
useKeyboardShortcuts()
|
||||||
useWatchDeviceAvailability()
|
|
||||||
|
|
||||||
const clearRouterState = () => {
|
const clearRouterState = () => {
|
||||||
if (window?.history?.state) {
|
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 { Spinner } from '@/primitives/Spinner'
|
||||||
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
|
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
|
||||||
import { PopupWindow } from '../utils/PopupWindow'
|
import { PopupWindow } from '../utils/PopupWindow'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
const callbackIdHandler = new CallbackIdHandler()
|
const callbackIdHandler = new CallbackIdHandler()
|
||||||
const popupWindow = new PopupWindow()
|
const popupWindow = new PopupWindow()
|
||||||
@@ -53,9 +52,7 @@ const CreatePopup = () => {
|
|||||||
popupWindow.close()
|
popupWindow.close()
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reportError('generic_failure', error, {
|
console.error('Failed to create meeting room:', error)
|
||||||
context: 'Failed to create meeting room:',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isLoggedIn && callbackId) {
|
if (isLoggedIn && callbackId) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
|||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
@@ -109,7 +108,7 @@ const SettingsPopup = () => {
|
|||||||
patchRoom({
|
patchRoom({
|
||||||
roomId: roomSlug,
|
roomId: roomSlug,
|
||||||
room: { configuration: newConfiguration },
|
room: { configuration: newConfiguration },
|
||||||
}).catch((e) => reportError('generic_failure', e))
|
}).catch((e) => console.error(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSource = (sources: Source[], enabled: boolean) => {
|
const updateSource = (sources: Source[], enabled: boolean) => {
|
||||||
@@ -330,7 +329,7 @@ const SettingsPopup = () => {
|
|||||||
patchRoom({
|
patchRoom({
|
||||||
roomId: roomSlug,
|
roomId: roomSlug,
|
||||||
room: { access_level: value as ApiAccessLevel },
|
room: { access_level: value as ApiAccessLevel },
|
||||||
}).catch((e) => reportError('generic_failure', e))
|
}).catch((e) => console.error(e))
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { authUrl } from '@/features/auth/utils/authUrl'
|
import { authUrl } from '@/features/auth/utils/authUrl'
|
||||||
import { PopupMessageType, CallbackCreationRoomData } from './types'
|
import { PopupMessageType, CallbackCreationRoomData } from './types'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export class PopupWindow {
|
export class PopupWindow {
|
||||||
private sendMessageToManager(
|
private sendMessageToManager(
|
||||||
@@ -10,7 +9,7 @@ export class PopupWindow {
|
|||||||
callback?: () => void
|
callback?: () => void
|
||||||
) {
|
) {
|
||||||
if (!window.opener) {
|
if (!window.opener) {
|
||||||
reportError('generic_failure', new Error('No manager window found'))
|
console.error('No manager window found')
|
||||||
window.close()
|
window.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user