mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-22 16:16:52 +00:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06494371cc | |||
| 4f5d5d1b9a | |||
| e5f0b1c202 | |||
| 9e0d57a8c6 | |||
| 7ba0803b71 | |||
| beacfc3d3f | |||
| 52e5d99e83 | |||
| 15ca2b41b4 | |||
| e34f3dd219 | |||
| feb573551f | |||
| 1d0a0cc637 | |||
| eae93f771f | |||
| 45175a2a54 | |||
| 8b22059b18 | |||
| 87dbd8069d | |||
| c7420c59a3 | |||
| f46babfcbd | |||
| 7c465f2148 | |||
| d005f202c6 | |||
| a82023f8b0 | |||
| cc9dae66db | |||
| 8d000fc6d9 | |||
| b7abd0ae6e | |||
| 40e4f17c65 | |||
| 77c5329f8a | |||
| c8ec1c8a9d | |||
| 01e004e272 | |||
| cbfb97eb54 | |||
| ac503b3ae5 | |||
| 52f119db02 | |||
| 387ae17c22 | |||
| 1eb6f0b9e7 | |||
| 047a4c9f3f | |||
| 6c4f0632b8 | |||
| ff7a1a4f33 | |||
| 7d1ce5f215 | |||
| 22ab89994b | |||
| e1a28f315d | |||
| dffcb83fff | |||
| c838229ec9 | |||
| ab40ec365d | |||
| 199c0297d4 | |||
| 089db20a2e | |||
| f0c08bea92 | |||
| 03e90b6178 | |||
| c53a2f8af4 | |||
| 7461cd28ce | |||
| 5723f29cef | |||
| 68a5e84f5d | |||
| b84ee74ee2 | |||
| 0dd2478c3e | |||
| 8f27b89d21 | |||
| b780d2845a | |||
| 751d029ac9 | |||
| aaa51a4457 | |||
| c8a3ef6f61 | |||
| 5d50671b3c | |||
| 8615bf879c | |||
| 186d16c46f | |||
| fb3ee56702 | |||
| 48c0cb320e | |||
| d810c9e0de | |||
| 134d9a188f | |||
| ea7188059d | |||
| b8958e6e87 | |||
| 23bb3c39d0 |
@@ -8,6 +8,78 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 📈(frontend) track errors when starting or stopping a recording
|
||||||
|
- 🚸(frontend) explain camera-in-use failures on the join screen
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- 💄(frontend) increase the blur intensity
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 📝(docs) fix minor typos in comments and docstrings
|
||||||
|
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
|
||||||
|
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
|
||||||
|
- 🐛(frontend) treat client-initiated connect aborts as events
|
||||||
|
- 🐛(frontend) use state instead of a ref for MoreControls container
|
||||||
|
- 🐛(frontend) stop init_virtual_background from firing on blur updates
|
||||||
|
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
|
||||||
|
- 🐛(frontend) fix joined notification tile no longer rendering properly
|
||||||
|
|
||||||
|
## [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
|
## [1.25.2] - 2026-08-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+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 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
|
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
|
||||||
|
|
||||||
We appreciate your effort to make Visio more secure.
|
We appreciate your effort to make Visio more secure.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -180,7 +180,7 @@ services:
|
|||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|
||||||
node:
|
node:
|
||||||
image: node:18
|
image: node:22
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
environment:
|
environment:
|
||||||
HOME: /tmp
|
HOME: /tmp
|
||||||
|
|||||||
@@ -50,10 +50,24 @@ 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'
|
||||||
@@ -117,6 +131,19 @@ 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.25.2"
|
version = "1.27.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.25.2"
|
version = "1.27.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -41,6 +44,7 @@ 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,6 +5,7 @@ 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
|
||||||
@@ -88,6 +89,155 @@ 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.25.2"
|
version = "1.27.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.25.2"
|
version = "1.27.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -2252,11 +2252,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.5.5"
|
version = "0.6.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Generated
+70
-77
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.25.2",
|
"version": "1.27.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.25.2",
|
"version": "1.27.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.21",
|
"@livekit/components-react": "2.9.23",
|
||||||
"@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.14",
|
||||||
@@ -22,20 +22,19 @@
|
|||||||
"@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.1",
|
"i18next": "26.3.6",
|
||||||
"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.20.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.395.0",
|
"posthog-js": "1.404.1",
|
||||||
"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.8",
|
"react-i18next": "17.0.10",
|
||||||
"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",
|
||||||
@@ -743,28 +742,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.5",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/utils": "^0.2.11"
|
"@floating-ui/utils": "^0.2.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/dom": {
|
"node_modules/@floating-ui/dom": {
|
||||||
"version": "1.7.4",
|
"version": "1.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.3",
|
"@floating-ui/core": "^1.7.5",
|
||||||
"@floating-ui/utils": "^0.2.10"
|
"@floating-ui/utils": "^0.2.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/utils": {
|
"node_modules/@floating-ui/utils": {
|
||||||
"version": "0.2.11",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
||||||
@@ -967,12 +966,12 @@
|
|||||||
"license": "GPL-3.0+"
|
"license": "GPL-3.0+"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/components-core": {
|
"node_modules/@livekit/components-core": {
|
||||||
"version": "0.12.13",
|
"version": "0.12.14",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
|
||||||
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
|
"integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "1.7.4",
|
"@floating-ui/dom": "1.7.6",
|
||||||
"loglevel": "1.9.1",
|
"loglevel": "1.9.1",
|
||||||
"rxjs": "7.8.2"
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
@@ -980,17 +979,17 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"livekit-client": "^2.17.2",
|
"livekit-client": "^2.20.1",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/components-react": {
|
"node_modules/@livekit/components-react": {
|
||||||
"version": "2.9.21",
|
"version": "2.9.23",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
|
||||||
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==",
|
"integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-core": "0.12.13",
|
"@livekit/components-core": "0.12.14",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.0.12",
|
"jose": "^6.0.12",
|
||||||
@@ -1000,8 +999,8 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
|
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0",
|
||||||
"livekit-client": "^2.18.2",
|
"livekit-client": "^2.20.1",
|
||||||
"react": ">=18",
|
"react": ">=18",
|
||||||
"react-dom": ">=18",
|
"react-dom": ">=18",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
@@ -1028,9 +1027,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/protocol": {
|
"node_modules/@livekit/protocol": {
|
||||||
"version": "1.46.6",
|
"version": "1.50.4",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
|
||||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
@@ -1721,18 +1720,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/core": {
|
"node_modules/@posthog/core": {
|
||||||
"version": "1.39.5",
|
"version": "1.48.3",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
||||||
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==",
|
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/types": "^1.392.0"
|
"@posthog/types": "^1.405.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/types": {
|
"node_modules/@posthog/types": {
|
||||||
"version": "1.392.0",
|
"version": "1.405.0",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
||||||
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==",
|
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/overlays": {
|
"node_modules/@react-aria/overlays": {
|
||||||
@@ -4394,11 +4393,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-js": {
|
"node_modules/core-js": {
|
||||||
"version": "3.39.0",
|
"version": "3.50.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||||
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
|
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||||
"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"
|
||||||
@@ -4710,15 +4712,6 @@
|
|||||||
"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",
|
||||||
@@ -6410,9 +6403,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.1",
|
"version": "26.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6429,7 +6422,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5 || ^6"
|
"typescript": "^5 || ^6 || ^7"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"typescript": {
|
"typescript": {
|
||||||
@@ -8119,20 +8112,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.20.0",
|
"version": "2.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
|
||||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
"@livekit/protocol": "1.46.6",
|
"@livekit/protocol": "1.50.4",
|
||||||
"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.5"
|
"webrtc-adapter": "9.0.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/dom-mediacapture-record": "^1"
|
"@types/dom-mediacapture-record": "^1"
|
||||||
@@ -9130,17 +9123,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.395.0",
|
"version": "1.404.1",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
|
||||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
"integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "(Apache-2.0 AND MIT)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.38.0",
|
"@posthog/core": "^1.43.1",
|
||||||
"@posthog/types": "^1.391.1",
|
"@posthog/types": "^1.397.0",
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.49.0",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.29.2",
|
"preact": "^10.29.3",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.3.0"
|
"web-vitals": "^5.3.0"
|
||||||
}
|
}
|
||||||
@@ -9432,9 +9425,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.8",
|
"version": "17.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^7.29.2",
|
||||||
@@ -9444,7 +9437,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"i18next": ">= 26.2.0",
|
"i18next": ">= 26.2.0",
|
||||||
"react": ">= 16.8.0",
|
"react": ">= 16.8.0",
|
||||||
"typescript": "^5 || ^6"
|
"typescript": "^5 || ^6 || ^7"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
@@ -11655,9 +11648,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||||
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
||||||
"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.25.2",
|
"version": "1.27.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"@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.21",
|
"@livekit/components-react": "2.9.23",
|
||||||
"@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.14",
|
||||||
@@ -29,20 +29,19 @@
|
|||||||
"@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.1",
|
"i18next": "26.3.6",
|
||||||
"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.20.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.395.0",
|
"posthog-js": "1.404.1",
|
||||||
"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.8",
|
"react-i18next": "17.0.10",
|
||||||
"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,24 +2,31 @@ 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 { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
|
const { devices, activeDeviceId } = useMediaDeviceSelect({
|
||||||
|
kind: 'audiooutput',
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateActiveId = async (deviceId: string) => {
|
if (!canTestAudioOutput() || !activeDeviceId) return
|
||||||
try {
|
if (!devices.some((device) => device.deviceId === activeDeviceId)) return
|
||||||
await audioRef?.current?.setSinkId(deviceId)
|
audioRef.current?.setSinkId(activeDeviceId).catch((error) => {
|
||||||
} catch (error) {
|
if (error instanceof DOMException && error.name === 'NotFoundError') {
|
||||||
console.error(`Error setting sinkId: ${error}`)
|
return
|
||||||
}
|
}
|
||||||
}
|
reportError(
|
||||||
updateActiveId(activeDeviceId)
|
'device_switch_failure',
|
||||||
}, [activeDeviceId])
|
new Error(`Error setting sinkId: ${error}`)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [devices, activeDeviceId])
|
||||||
|
|
||||||
// prevent pausing the sound
|
// prevent pausing the sound
|
||||||
navigator.mediaSession.setActionHandler('pause', function () {})
|
navigator.mediaSession.setActionHandler('pause', function () {})
|
||||||
@@ -28,9 +35,13 @@ export const SoundTester = () => {
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondaryText"
|
variant="secondaryText"
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
audioRef?.current?.play()
|
try {
|
||||||
|
await audioRef?.current?.play()
|
||||||
setIsPlaying(true)
|
setIsPlaying(true)
|
||||||
|
} catch {
|
||||||
|
setIsPlaying(false)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
isDisabled={isPlaying}
|
isDisabled={isPlaying}
|
||||||
@@ -44,7 +55,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)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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,15 +1,8 @@
|
|||||||
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'
|
||||||
let posthog: PostHog | null = null
|
import { filterExceptions } from '../exceptionFilters'
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -38,7 +31,6 @@ export const useAnalytics = ({
|
|||||||
flags_api_host,
|
flags_api_host,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
}: useAnalyticsProps) => {
|
}: useAnalyticsProps) => {
|
||||||
const [location] = useLocation()
|
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,6 +41,14 @@ 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,12 +58,5 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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'
|
||||||
|
| 'device-in-use'
|
||||||
|
| '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()) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PostHog } from 'posthog-js'
|
||||||
|
|
||||||
|
let posthog: PostHog | null = null
|
||||||
|
|
||||||
|
export const getPosthog = async () => {
|
||||||
|
if (!posthog) posthog = (await import('posthog-js')).default
|
||||||
|
return posthog
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ 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.
|
||||||
@@ -42,6 +44,11 @@ export const useSyncUserPreferencesWithBackend = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncBrowserPreferencesToBackend()
|
syncBrowserPreferencesToBackend().catch((error) => {
|
||||||
|
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 '@livekit/components-react'
|
import { formatChatMessageLinks } from '../utils'
|
||||||
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()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Tigger the message notification (temporary)
|
// Trigger 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
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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,6 +15,7 @@ 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 (
|
||||||
@@ -160,7 +161,11 @@ const Home = () => {
|
|||||||
window.location.replace(data.external_home_url)
|
window.location.replace(data.external_home_url)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRedirectFailed(true)
|
setRedirectFailed(true)
|
||||||
console.error('Site is not reachable:', error)
|
captureEvent('external-home-unreachable', {
|
||||||
|
error_name: error instanceof Error ? error.name : 'Unknown',
|
||||||
|
error_message:
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useToast } from 'react-aria'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
@@ -11,6 +11,7 @@ import { Div } from '@/primitives'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
import { setPinnedTrack } from '@/stores/layout'
|
import { setPinnedTrack } from '@/stores/layout'
|
||||||
|
import { useParticipantTracks } from '@livekit/components-react'
|
||||||
|
|
||||||
const ClickableToast = styled(RACButton, {
|
const ClickableToast = styled(RACButton, {
|
||||||
base: {
|
base: {
|
||||||
@@ -30,13 +31,17 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
)
|
)
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
|
|
||||||
if (!participant) return
|
const [cameraTrack] = useParticipantTracks(
|
||||||
|
[Source.Camera],
|
||||||
|
participant?.identity
|
||||||
|
)
|
||||||
|
|
||||||
const trackReference = {
|
const trackReference = useMemo(
|
||||||
participant,
|
() => cameraTrack ?? { participant, source: Source.Camera },
|
||||||
publication: participant.getTrackPublication(Source.Camera),
|
[cameraTrack, participant]
|
||||||
source: Source.Camera,
|
)
|
||||||
}
|
|
||||||
|
if (!participant) return
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
|||||||
@@ -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,6 +4,7 @@ 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(
|
||||||
@@ -55,7 +56,9 @@ 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
|
||||||
console.error('Failed to decode notification payload:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to decode notification payload:',
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-17
@@ -1,11 +1,9 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
import { Participant, Track } from 'livekit-client'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
import { openMuteDialog } from '@/stores/muteDialog'
|
||||||
|
|
||||||
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
@@ -15,31 +13,18 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
|
|||||||
source: Track.Source.Microphone,
|
source: Track.Source.Microphone,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { muteParticipant } = useMuteParticipant()
|
|
||||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
|
||||||
|
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isMuted}
|
isDisabled={isMuted}
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
variant={'primaryTextDark'}
|
variant={'primaryTextDark'}
|
||||||
square
|
square
|
||||||
onPress={() => setIsAlertOpen(true)}
|
onPress={() => openMuteDialog(participant)}
|
||||||
tooltip={t('muteParticipant', { name })}
|
tooltip={t('muteParticipant', { name })}
|
||||||
>
|
>
|
||||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||||
</Button>
|
</Button>
|
||||||
<MuteAlertDialog
|
|
||||||
isOpen={isAlertOpen}
|
|
||||||
onSubmit={() =>
|
|
||||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
|
||||||
}
|
|
||||||
onClose={() => setIsAlertOpen(false)}
|
|
||||||
name={name}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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()
|
||||||
@@ -11,7 +12,9 @@ export const useLowerHandParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('An error occurred while lowering hands :', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'An error occurred while lowering hands :',
|
||||||
|
})
|
||||||
throw new Error('An error occurred while lowering hands.', {
|
throw new Error('An error occurred while lowering hands.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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()
|
||||||
@@ -22,9 +23,12 @@ export const useParticipantRole = () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
reportError(
|
||||||
|
'generic_failure',
|
||||||
|
new Error(
|
||||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { updateParticipantRole }
|
return { updateParticipantRole }
|
||||||
|
|||||||
@@ -19,13 +19,11 @@ import {
|
|||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { useState } from 'react'
|
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||||
import { PinBadge } from './PinBadge'
|
import { PinBadge } from './PinBadge'
|
||||||
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
||||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
import { openMuteDialog } from '@/stores/muteDialog'
|
||||||
import { ParticipantName } from './ParticipantName'
|
import { ParticipantName } from './ParticipantName'
|
||||||
|
|
||||||
type MicIndicatorProps = {
|
type MicIndicatorProps = {
|
||||||
@@ -34,7 +32,6 @@ type MicIndicatorProps = {
|
|||||||
|
|
||||||
const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const { muteParticipant } = useMuteParticipant()
|
|
||||||
const { isMuted } = useTrackMutedIndicator({
|
const { isMuted } = useTrackMutedIndicator({
|
||||||
participant: participant,
|
participant: participant,
|
||||||
source: Source.Microphone,
|
source: Source.Microphone,
|
||||||
@@ -42,7 +39,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
|
|
||||||
const canMute = useCanMute(participant)
|
const canMute = useCanMute(participant)
|
||||||
const isSpeaking = useIsSpeaking(participant)
|
const isSpeaking = useIsSpeaking(participant)
|
||||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
const label = isLocal(participant)
|
const label = isLocal(participant)
|
||||||
@@ -52,7 +48,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
square
|
square
|
||||||
variant="greyscale"
|
variant="greyscale"
|
||||||
@@ -62,10 +57,8 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
isDisabled={isMuted || !canMute}
|
isDisabled={isMuted || !canMute}
|
||||||
onPress={async () =>
|
onPress={async () =>
|
||||||
!isMuted && isLocal(participant)
|
!isMuted && isLocal(participant)
|
||||||
? await (participant as LocalParticipant)?.setMicrophoneEnabled(
|
? await (participant as LocalParticipant)?.setMicrophoneEnabled(false)
|
||||||
false
|
: openMuteDialog(participant)
|
||||||
)
|
|
||||||
: setIsAlertOpen(true)
|
|
||||||
}
|
}
|
||||||
data-attr="participants-mute"
|
data-attr="participants-mute"
|
||||||
>
|
>
|
||||||
@@ -83,15 +76,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<MuteAlertDialog
|
|
||||||
isOpen={isAlertOpen}
|
|
||||||
onSubmit={() =>
|
|
||||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
|
||||||
}
|
|
||||||
onClose={() => setIsAlertOpen(false)}
|
|
||||||
name={name}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ export const useWaitingParticipants = () => {
|
|||||||
|
|
||||||
await refetchWaiting()
|
await refetchWaiting()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
reportError('generic_failure', e)
|
||||||
setListEnabled(true)
|
setListEnabled(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ 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')
|
||||||
@@ -19,7 +21,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!container) return null
|
if (!container || !container.isConnected) return null
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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
|
||||||
@@ -59,21 +61,29 @@ 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 {
|
||||||
const pipWindow =
|
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 })
|
||||||
@@ -82,8 +92,10 @@ export const usePictureInPicture = () => {
|
|||||||
})
|
})
|
||||||
documentPictureInPictureStore.window = ref(pipWindow)
|
documentPictureInPictureStore.window = ref(pipWindow)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
reportError('generic_failure', error, {
|
||||||
console.error('Failed to open Picture-in-Picture window', error)
|
context: 'pip_init_failure',
|
||||||
|
})
|
||||||
|
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,
|
||||||
transform: 'translateY(3.25rem)',
|
translate: '0 3.25rem',
|
||||||
transition: 'opacity, transform',
|
transition: 'opacity, translate',
|
||||||
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,
|
||||||
transform: 'translateY(0)',
|
translate: '0 0',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
shouldBeCenteredWithToggleButton,
|
shouldBeCenteredWithToggleButton,
|
||||||
setShouldBeCenteredWithToggleButton,
|
setShouldBeCenteredWithToggleButton,
|
||||||
] = useState(false)
|
] = useState(false)
|
||||||
const [rightOffset, setRightOffset] = useState(0)
|
const [offsetX, setOffsetX] = 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) {
|
||||||
setRightOffset(0)
|
setOffsetX(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
|
||||||
setRightOffset((prev) => prev - shift * 2)
|
setOffsetX((prev) => prev + shift)
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
|
|||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
style={
|
style={
|
||||||
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
||||||
? { marginRight: `${rightOffset}px` }
|
? { transform: `translateX(${offsetX}px)` }
|
||||||
: { margin: '0 15px' }
|
: { margin: '0 15px' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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'
|
||||||
@@ -29,6 +28,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'
|
||||||
|
|
||||||
export const ScreenRecordingSidePanel = () => {
|
export const ScreenRecordingSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -63,7 +63,7 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingRequested,
|
type: NotificationType.ScreenRecordingRequested,
|
||||||
})
|
})
|
||||||
posthog.capture('screen-recording-requested', {})
|
captureEvent('screen-recording-requested', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScreenRecording = async () => {
|
const handleScreenRecording = async () => {
|
||||||
@@ -100,13 +100,15 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingStarted,
|
type: NotificationType.ScreenRecordingStarted,
|
||||||
})
|
})
|
||||||
posthog.capture('screen-recording-started', {
|
captureEvent('screen-recording-started', {
|
||||||
includeTranscript: includeTranscript,
|
includeTranscript: includeTranscript,
|
||||||
language: selectedLanguageKey,
|
language: selectedLanguageKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to handle recording:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to handle recording:',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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'
|
||||||
@@ -35,6 +34,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 { 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()
|
||||||
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionRequested,
|
type: NotificationType.TranscriptionRequested,
|
||||||
})
|
})
|
||||||
posthog.capture('transcript-requested', {})
|
captureEvent('transcript-requested', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTranscript = async () => {
|
const handleTranscript = async () => {
|
||||||
@@ -121,13 +121,15 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionStarted,
|
type: NotificationType.TranscriptionStarted,
|
||||||
})
|
})
|
||||||
posthog.capture('transcript-started', {
|
captureEvent('transcript-started', {
|
||||||
includeScreenRecording: includeScreenRecording,
|
includeScreenRecording: includeScreenRecording,
|
||||||
language: selectedLanguageKey,
|
language: selectedLanguageKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to handle transcript:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to handle transcript:',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
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,5 +1,6 @@
|
|||||||
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()
|
||||||
@@ -8,7 +9,9 @@ export const useRoomMetadata = () => {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(metadata)
|
return JSON.parse(metadata)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse room metadata:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to parse room metadata:',
|
||||||
|
})
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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()
|
||||||
@@ -31,7 +32,10 @@ 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) {
|
||||||
console.error('Cannot mute participant: missing auth token')
|
reportError(
|
||||||
|
'participant_mute_api_failure',
|
||||||
|
new Error('Cannot mute participant: missing auth token')
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,9 +57,12 @@ export const useMuteParticipant = () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
reportError(
|
||||||
|
'participant_mute_api_failure',
|
||||||
|
new Error(
|
||||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
)
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +72,12 @@ export const useMuteParticipant = () => {
|
|||||||
destinationIdentities: [participant.identity],
|
destinationIdentities: [participant.identity],
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
reportError(
|
||||||
|
'participant_mute_api_failure',
|
||||||
|
new Error(
|
||||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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()
|
||||||
@@ -11,7 +12,9 @@ export const useMuteParticipants = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('An error occurred while muting participants :', error)
|
reportError('participant_mute_api_failure', error, {
|
||||||
|
context: 'An error occurred while muting participants :',
|
||||||
|
})
|
||||||
throw new Error('An error occurred while muting participants.', {
|
throw new Error('An error occurred while muting participants.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 = () => {
|
||||||
@@ -32,9 +33,12 @@ export const useParticipantPermissions = () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
reportError(
|
||||||
|
'permissions_api_failure',
|
||||||
|
new Error(
|
||||||
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
)
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { updateParticipantPermissions }
|
return { updateParticipantPermissions }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 = () => {
|
||||||
@@ -15,7 +16,9 @@ export const useUpdateParticipantsPermissions = () => {
|
|||||||
)
|
)
|
||||||
return Promise.all(promises)
|
return Promise.all(promises)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('An error occurred while updating permissions :', error)
|
reportError('permissions_api_failure', error, {
|
||||||
|
context: 'An error occurred while updating permissions :',
|
||||||
|
})
|
||||||
throw new Error('An error occurred while updating permissions.', {
|
throw new Error('An error occurred while updating permissions.', {
|
||||||
cause: error,
|
cause: error,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
usePersistentUserChoices,
|
usePersistentUserChoices,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
|
ConnectionError,
|
||||||
|
ConnectionErrorReason,
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
Room,
|
Room,
|
||||||
@@ -25,18 +27,21 @@ 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 { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
import {
|
||||||
import { usePostHog } from 'posthog-js/react'
|
captureEvent,
|
||||||
|
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,
|
||||||
@@ -47,7 +52,6 @@ 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 {
|
||||||
@@ -57,8 +61,8 @@ export const Conference = ({
|
|||||||
const { username } = useSnapshot(userStore)
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
posthog.capture('visit-room', { slug: roomId })
|
void captureMediaEvent('visit-room', { slug: roomId })
|
||||||
}, [roomId, posthog])
|
}, [roomId])
|
||||||
const fetchKey = [keys.room, roomId]
|
const fetchKey = [keys.room, roomId]
|
||||||
|
|
||||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||||
@@ -170,14 +174,6 @@ 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)
|
||||||
@@ -235,7 +231,21 @@ export const Conference = ({
|
|||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
posthog.captureException(e)
|
const failure = MediaDeviceFailure.getFailure(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
|
||||||
@@ -253,23 +263,8 @@ 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(
|
||||||
@@ -295,18 +290,10 @@ 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 = getRouteUrl('room', roomData?.slug)
|
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
|
||||||
|
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
|||||||
|
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,13 +1,114 @@
|
|||||||
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 { Dialog, H } from '@/primitives'
|
import { Button, Dialog, H, P } 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 { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
|
import {
|
||||||
|
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.
|
||||||
@@ -65,6 +166,8 @@ export const Permissions = () => {
|
|||||||
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
|
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<SystemPermissions />
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={permissions.isPermissionDialogOpen}
|
isOpen={permissions.isPermissionDialogOpen}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -115,7 +218,10 @@ export const Permissions = () => {
|
|||||||
<>
|
<>
|
||||||
{descriptionBeforeIcon}
|
{descriptionBeforeIcon}
|
||||||
<span
|
<span
|
||||||
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiEqualizer2Line />
|
<RiEqualizer2Line />
|
||||||
</span>
|
</span>
|
||||||
@@ -128,5 +234,6 @@ export const Permissions = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Button, H, Input, Text, TextArea } from '@/primitives'
|
import { Button, H, 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 type { CandidateInfo } from '@/stores/connectionObserver'
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
const Card = styled('div', {
|
const Card = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
@@ -72,11 +70,9 @@ 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>
|
||||||
}) => {
|
}) => {
|
||||||
@@ -90,7 +86,7 @@ const OpenFeedback = ({
|
|||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
try {
|
try {
|
||||||
posthog.capture('open-feedback', {
|
captureEvent('open-feedback', {
|
||||||
feedback,
|
feedback,
|
||||||
...metadata,
|
...metadata,
|
||||||
})
|
})
|
||||||
@@ -141,12 +137,10 @@ 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
|
||||||
@@ -160,7 +154,7 @@ const RateQuality = ({
|
|||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
try {
|
try {
|
||||||
posthog.capture('quality-rating', {
|
captureEvent('quality-rating', {
|
||||||
rating: selectedRating,
|
rating: selectedRating,
|
||||||
...metadata,
|
...metadata,
|
||||||
})
|
})
|
||||||
@@ -243,73 +237,8 @@ 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 = ({
|
||||||
@@ -318,12 +247,6 @@ 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(), [])
|
||||||
@@ -339,37 +262,14 @@ export const Rating = ({
|
|||||||
if (!isAnalyticsEnabled) return
|
if (!isAnalyticsEnabled) return
|
||||||
|
|
||||||
if (step == 0) {
|
if (step == 0) {
|
||||||
return (
|
return <RateQuality onNext={() => setStep(step + 1)} metadata={metadata} />
|
||||||
<RateQuality
|
|
||||||
posthog={posthog}
|
|
||||||
onNext={() => setStep(step + 1)}
|
|
||||||
metadata={metadata}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step == 1) {
|
if (step == 1) {
|
||||||
return (
|
return <OpenFeedback onNext={() => setStep(step + 1)} metadata={metadata} />
|
||||||
<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)} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
|
||||||
|
import { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
|
||||||
|
import type { PermissionKind } from '@/stores/permissions'
|
||||||
|
|
||||||
|
const RETRY_INTERVAL_MS = 30_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* There is no browser event for "another app released the device", so
|
||||||
|
* while a device is flagged in use, re-probe it periodically. Only clears
|
||||||
|
* the flag (the toggle becomes usable again); it never re-enables the
|
||||||
|
* device on the user's behalf.
|
||||||
|
*/
|
||||||
|
export function useWatchDeviceReleased() {
|
||||||
|
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
||||||
|
useWatchKind('camera', cameraInUse)
|
||||||
|
useWatchKind('microphone', microphoneInUse)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWatchKind(kind: PermissionKind, inUse: boolean) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inUse) return
|
||||||
|
const id = setInterval(
|
||||||
|
() => void probeDeviceReleased(kind),
|
||||||
|
RETRY_INTERVAL_MS
|
||||||
|
)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [kind, inUse])
|
||||||
|
}
|
||||||
@@ -1,160 +1,36 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { permissionsStore } from '@/stores/permissions'
|
import { syncPermissions } from '@/stores/permissions'
|
||||||
import { isSafari } from '@/utils/livekit'
|
|
||||||
|
|
||||||
const POLLING_TIME = 500
|
|
||||||
|
|
||||||
export const useWatchPermissions = () => {
|
export const useWatchPermissions = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cleanup: (() => void) | undefined
|
const sync = () => void syncPermissions()
|
||||||
let intervalId: ReturnType<typeof setTimeout> | undefined
|
sync()
|
||||||
let isCancelled = false
|
|
||||||
|
|
||||||
const checkPermissions = async () => {
|
navigator.mediaDevices?.addEventListener?.('devicechange', sync)
|
||||||
try {
|
window.addEventListener('focus', sync)
|
||||||
if (!navigator.permissions) {
|
|
||||||
if (!isCancelled) {
|
|
||||||
permissionsStore.cameraPermission = 'unavailable'
|
|
||||||
permissionsStore.microphonePermission = 'unavailable'
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const [cameraPermission, microphonePermission] = await Promise.all([
|
let statuses: PermissionStatus[] = []
|
||||||
navigator.permissions.query({ name: 'camera' }),
|
let cancelled = false
|
||||||
navigator.permissions.query({ name: 'microphone' }),
|
if (navigator.permissions) {
|
||||||
|
Promise.all([
|
||||||
|
navigator.permissions.query({ name: 'camera' as PermissionName }),
|
||||||
|
navigator.permissions.query({ name: 'microphone' as PermissionName }),
|
||||||
])
|
])
|
||||||
|
.then((results) => {
|
||||||
if (isCancelled) return
|
if (cancelled) return
|
||||||
|
statuses = results
|
||||||
/**
|
statuses.forEach((s) => s.addEventListener('change', sync))
|
||||||
* Safari Permission API Limitation Workaround
|
})
|
||||||
*
|
.catch(() => {
|
||||||
* Safari has a known issue where permission change events are not reliably fired
|
// Query unsupported: devicechange/focus + gUM outcomes cover it.
|
||||||
* when users interact with permission prompts. This is documented in Apple's forums:
|
})
|
||||||
* https://developer.apple.com/forums/thread/757353
|
|
||||||
*
|
|
||||||
* The problem:
|
|
||||||
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
|
|
||||||
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
|
|
||||||
* - This leaves the UI in an inconsistent state showing outdated permission status
|
|
||||||
*
|
|
||||||
* The solution:
|
|
||||||
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
|
|
||||||
* - Continue polling until both permissions are no longer in 'prompt' state
|
|
||||||
* - This ensures we catch permission changes even when Safari fails to fire events
|
|
||||||
*
|
|
||||||
* This polling is Safari-specific and only activates when needed to minimize performance impact.
|
|
||||||
*/
|
|
||||||
if (
|
|
||||||
isSafari() &&
|
|
||||||
(cameraPermission.state === 'prompt' ||
|
|
||||||
microphonePermission.state === 'prompt')
|
|
||||||
) {
|
|
||||||
// Start polling every 1 second if either permission is in 'prompt' state
|
|
||||||
if (!intervalId) {
|
|
||||||
intervalId = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const [updatedCamera, updatedMicrophone] = await Promise.all([
|
|
||||||
navigator.permissions.query({ name: 'camera' }),
|
|
||||||
navigator.permissions.query({ name: 'microphone' }),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (isCancelled) return
|
|
||||||
|
|
||||||
const cameraChanged =
|
|
||||||
permissionsStore.cameraPermission !== updatedCamera.state
|
|
||||||
const microphoneChanged =
|
|
||||||
permissionsStore.microphonePermission !==
|
|
||||||
updatedMicrophone.state
|
|
||||||
|
|
||||||
if (cameraChanged) {
|
|
||||||
permissionsStore.cameraPermission = updatedCamera.state
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (microphoneChanged) {
|
|
||||||
permissionsStore.microphonePermission =
|
|
||||||
updatedMicrophone.state
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
updatedCamera.state !== 'prompt' &&
|
|
||||||
updatedMicrophone.state !== 'prompt'
|
|
||||||
) {
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCancelled) {
|
|
||||||
console.error('Error polling permissions:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, POLLING_TIME)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
permissionsStore.cameraPermission = cameraPermission.state
|
|
||||||
permissionsStore.microphonePermission = microphonePermission.state
|
|
||||||
|
|
||||||
const handleCameraChange = (e: Event) => {
|
|
||||||
const target = e.target as PermissionStatus
|
|
||||||
permissionsStore.cameraPermission = target.state
|
|
||||||
|
|
||||||
if (
|
|
||||||
intervalId &&
|
|
||||||
target.state !== 'prompt' &&
|
|
||||||
microphonePermission.state !== 'prompt'
|
|
||||||
) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMicrophoneChange = (e: Event) => {
|
|
||||||
const target = e.target as PermissionStatus
|
|
||||||
permissionsStore.microphonePermission = target.state
|
|
||||||
|
|
||||||
if (
|
|
||||||
intervalId &&
|
|
||||||
target.state !== 'prompt' &&
|
|
||||||
microphonePermission.state !== 'prompt'
|
|
||||||
) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cameraPermission.addEventListener('change', handleCameraChange)
|
|
||||||
microphonePermission.addEventListener('change', handleMicrophoneChange)
|
|
||||||
|
|
||||||
cleanup = () => {
|
|
||||||
cameraPermission.removeEventListener('change', handleCameraChange)
|
|
||||||
microphonePermission.removeEventListener(
|
|
||||||
'change',
|
|
||||||
handleMicrophoneChange
|
|
||||||
)
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId)
|
|
||||||
intervalId = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isCancelled) {
|
|
||||||
console.error('Error checking permissions:', error)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!isCancelled) {
|
|
||||||
permissionsStore.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkPermissions()
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isCancelled = true
|
cancelled = true
|
||||||
cleanup?.()
|
navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
|
||||||
|
window.removeEventListener('focus', sync)
|
||||||
|
statuses.forEach((s) => s.removeEventListener('change', sync))
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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' })
|
||||||
@@ -205,7 +206,7 @@ export const Admin = () => {
|
|||||||
patchRoom({
|
patchRoom({
|
||||||
roomId,
|
roomId,
|
||||||
room: { access_level: value as ApiAccessLevel },
|
room: { access_level: value as ApiAccessLevel },
|
||||||
}).catch((e) => console.error(e))
|
}).catch((e) => reportError('generic_failure', e))
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,28 +9,16 @@ 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<
|
||||||
@@ -80,100 +68,6 @@ 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
|
||||||
|
|
||||||
@@ -182,23 +76,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()
|
||||||
posthog.capture('connection-event')
|
void captureMediaEvent('connection-event', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReconnect = () => {
|
const handleReconnect = () => {
|
||||||
posthog.capture('reconnect-event')
|
captureEvent('reconnect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReconnected = () => {
|
const handleReconnected = () => {
|
||||||
posthog.capture('reconnected-event')
|
captureEvent('reconnected-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSignalingConnect = () => {
|
const handleSignalingConnect = () => {
|
||||||
posthog.capture('signaling-connect-event')
|
captureEvent('signaling-connect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSignalingReconnect = () => {
|
const handleSignalingReconnect = () => {
|
||||||
posthog.capture('signaling-reconnect-event')
|
captureEvent('signaling-reconnect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDisconnect = (
|
const handleDisconnect = (
|
||||||
@@ -206,7 +100,7 @@ export const ConnectionObserver = () => {
|
|||||||
) => {
|
) => {
|
||||||
const connectionEndTime = Date.now()
|
const connectionEndTime = Date.now()
|
||||||
|
|
||||||
posthog.capture('disconnect-event', {
|
captureEvent('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 = getRouteUrl('room', data?.slug)
|
const roomUrl = data?.slug ? getRouteUrl('room', data.slug) : ''
|
||||||
|
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
|
import { closeMuteDialog, muteDialogStore } from '@/stores/muteDialog'
|
||||||
|
import { MuteAlertDialog } from './MuteAlertDialog'
|
||||||
|
|
||||||
|
export const MuteAlertDialogProvider = () => {
|
||||||
|
const { participant } = useSnapshot(muteDialogStore)
|
||||||
|
const { muteParticipant } = useMuteParticipant()
|
||||||
|
|
||||||
|
const lastNameRef = useRef('')
|
||||||
|
if (participant) {
|
||||||
|
lastNameRef.current = participant.name || participant.identity
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MuteAlertDialog
|
||||||
|
isOpen={!!participant}
|
||||||
|
name={lastNameRef.current}
|
||||||
|
onClose={closeMuteDialog}
|
||||||
|
onSubmit={() => {
|
||||||
|
const target = muteDialogStore.participant
|
||||||
|
if (!target) return
|
||||||
|
muteParticipant(target).then(closeMuteDialog)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
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 = ({
|
||||||
@@ -11,7 +18,8 @@ export const ScreenShareErrorModal = ({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
||||||
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
|
const os = getOS()
|
||||||
|
const settingsHref = SCREEN_CAPTURE_SETTINGS_LINKS[os]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -26,15 +34,16 @@ export const ScreenShareErrorModal = ({
|
|||||||
<>
|
<>
|
||||||
<P>
|
<P>
|
||||||
{t('message')}{' '}
|
{t('message')}{' '}
|
||||||
{isMac && (
|
{settingsHref && (
|
||||||
<>
|
<>
|
||||||
{t('macInstructions')}{' '}
|
{t('settingsInstructions')}{' '}
|
||||||
<A
|
<A
|
||||||
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
href={settingsHref}
|
||||||
|
target="_blank"
|
||||||
color="primary"
|
color="primary"
|
||||||
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
|
aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
|
||||||
>
|
>
|
||||||
{t('macSystemPreferences')}
|
{t(`settingsLabel.${os}`)}
|
||||||
</A>
|
</A>
|
||||||
.{' '}
|
.{' '}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
|
import type { LocalTrack } from 'livekit-client'
|
||||||
|
import { useSyncTrackDeviceId } from '../hooks/useSyncTrackDeviceId'
|
||||||
|
import {
|
||||||
|
saveAudioInputDeviceId,
|
||||||
|
saveVideoInputDeviceId,
|
||||||
|
} from '@/stores/userChoices'
|
||||||
|
|
||||||
|
export const SyncDevicePreferences = () => {
|
||||||
|
const { cameraTrack, microphoneTrack } = useLocalParticipant()
|
||||||
|
useSyncTrackDeviceId(
|
||||||
|
cameraTrack?.track as LocalTrack | undefined,
|
||||||
|
saveVideoInputDeviceId
|
||||||
|
)
|
||||||
|
useSyncTrackDeviceId(
|
||||||
|
microphoneTrack?.track as LocalTrack | undefined,
|
||||||
|
saveAudioInputDeviceId
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
+5
-7
@@ -1,5 +1,4 @@
|
|||||||
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,
|
||||||
@@ -18,6 +17,7 @@ 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 containg the video processing result, of which we extract as stream.
|
// Canvas containing 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 containg the inference result.
|
// Mask containing the inference result.
|
||||||
segmentationMask?: ImageData
|
segmentationMask?: ImageData
|
||||||
|
|
||||||
// The resized image of the video source.
|
// The resized image of the video source.
|
||||||
@@ -100,14 +100,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
await this.initSegmenter()
|
await this.initSegmenter()
|
||||||
this._initWorker()
|
this._initWorker()
|
||||||
|
|
||||||
posthog.capture('firefox-blurring-init')
|
captureEvent('firefox-blurring-init', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
_initVirtualBackgroundImage() {
|
_initVirtualBackgroundImage() {
|
||||||
if (this.options.type !== 'virtual') {
|
if (this.options.type !== 'virtual') {
|
||||||
throw new Error(
|
return
|
||||||
'Virtual background is only supported for virtual background'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsUpdate =
|
const needsUpdate =
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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,
|
||||||
@@ -16,6 +15,7 @@ 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()
|
||||||
|
|
||||||
posthog.capture('face-landmarks-init')
|
captureEvent('face-landmarks-init', {})
|
||||||
}
|
}
|
||||||
|
|
||||||
_initWorker() {
|
_initWorker() {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { ProcessorWrapper } from '@livekit/track-processors'
|
import {
|
||||||
|
ProcessorWrapper,
|
||||||
|
supportsBackgroundProcessors,
|
||||||
|
} from '@livekit/track-processors'
|
||||||
import type { Track, TrackProcessor } from 'livekit-client'
|
import type { Track, TrackProcessor } from 'livekit-client'
|
||||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||||
@@ -34,7 +37,9 @@ export class BackgroundProcessorFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static isSupported() {
|
static isSupported() {
|
||||||
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
|
return (
|
||||||
|
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static getProcessor(
|
static getProcessor(
|
||||||
@@ -45,7 +50,7 @@ export class BackgroundProcessorFactory {
|
|||||||
|
|
||||||
if (!isBlur && !isVirtual) return undefined
|
if (!isBlur && !isVirtual) return undefined
|
||||||
|
|
||||||
if (ProcessorWrapper.isSupported) {
|
if (supportsBackgroundProcessors()) {
|
||||||
return new UnifiedBackgroundTrackProcessor(config)
|
return new UnifiedBackgroundTrackProcessor(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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',
|
||||||
@@ -103,7 +104,11 @@ export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
|
|||||||
setActiveMediaDevice(device.deviceId)
|
setActiveMediaDevice(device.deviceId)
|
||||||
setFacingMode(target)
|
setFacingMode(target)
|
||||||
} else {
|
} else {
|
||||||
console.error('Cannot get user device with facingMode ' + target)
|
reportError(
|
||||||
|
'device_switch_failure',
|
||||||
|
new Error('Cannot get user device with facingMode ' + target),
|
||||||
|
{ path: 'switch_device', kind: 'videoinput', facing_mode: target }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
+13
-2
@@ -1,8 +1,12 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
import {
|
||||||
|
useLocalParticipant,
|
||||||
|
useTrackToggle,
|
||||||
|
UseTrackToggleProps,
|
||||||
|
} from '@livekit/components-react'
|
||||||
import { Button, Popover } from '@/primitives'
|
import { Button, Popover } from '@/primitives'
|
||||||
import { RiArrowUpSLine } from '@remixicon/react'
|
import { RiArrowUpSLine } from '@remixicon/react'
|
||||||
import { Track } from 'livekit-client'
|
import { LocalAudioTrack, Track } from 'livekit-client'
|
||||||
|
|
||||||
import { ToggleDevice } from './ToggleDevice'
|
import { ToggleDevice } from './ToggleDevice'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
@@ -51,6 +55,12 @@ 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}`)
|
||||||
@@ -111,6 +121,7 @@ export const AudioDevicesControl = ({
|
|||||||
context="room"
|
context="room"
|
||||||
kind={kind}
|
kind={kind}
|
||||||
id={audioDeviceId}
|
id={audioDeviceId}
|
||||||
|
track={localAudioTrack}
|
||||||
onSubmit={saveAudioInputDeviceId}
|
onSubmit={saveAudioInputDeviceId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
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
@@ -0,0 +1,134 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+13
-4
@@ -4,8 +4,17 @@ 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'
|
||||||
|
|
||||||
export const PermissionNeededButton = () => {
|
type PermissionNeededButtonProps = {
|
||||||
|
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({
|
||||||
@@ -17,9 +26,9 @@ export const PermissionNeededButton = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
aria-label={t('ariaLabel')}
|
aria-label={tooltip ? label : t('ariaLabel')}
|
||||||
tooltip={t('tooltip')}
|
tooltip={label}
|
||||||
onPress={() => openPermissionsDialog()}
|
onPress={onPress ?? (() => openPermissionsDialog())}
|
||||||
variant="permission"
|
variant="permission"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ 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 }>
|
||||||
|
|
||||||
@@ -18,6 +23,7 @@ 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 &
|
||||||
@@ -28,6 +34,7 @@ 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' })
|
||||||
@@ -74,6 +81,16 @@ 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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -84,6 +101,7 @@ 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' })
|
||||||
|
|
||||||
@@ -96,6 +114,25 @@ 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 (
|
||||||
@@ -116,6 +153,7 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+89
-13
@@ -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, useState } from 'react'
|
import { useMemo, useRef, 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,10 +12,17 @@ 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 { useDeviceInUse } from '../../../hooks/useDeviceInUse'
|
||||||
|
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||||
|
import { requestDevicePermission } from '../../../utils/mediaPermissions'
|
||||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||||
import type {
|
import type {
|
||||||
@@ -90,9 +97,49 @@ 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 deviceInUse = useDeviceInUse(kind)
|
||||||
|
const explainDeviceInUse = deviceInUse && context === 'room'
|
||||||
|
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||||
|
const silentMicWarning =
|
||||||
|
kind === 'audioinput' &&
|
||||||
|
silentMicStatus === 'silent' &&
|
||||||
|
!cannotUseDevice &&
|
||||||
|
!deviceMissing &&
|
||||||
|
!deviceInUse
|
||||||
const deviceShortcut = useDeviceShortcut(kind)
|
const deviceShortcut = useDeviceShortcut(kind)
|
||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
const isRequestingPermission = useRef(false)
|
||||||
|
const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
|
||||||
|
|
||||||
|
const mediaPath = context === 'join' ? 'join_preview' : 'room'
|
||||||
|
|
||||||
|
const onPress = async () => {
|
||||||
|
if (!enabled && deviceMissing) {
|
||||||
|
setAlertError(MediaDeviceFailure.NotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!cannotUseDevice && !deviceInUse) {
|
||||||
|
toggle()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isRequestingPermission.current) return
|
||||||
|
isRequestingPermission.current = true
|
||||||
|
try {
|
||||||
|
const acquired = await requestDevicePermission(kind, mediaPath)
|
||||||
|
if (acquired) {
|
||||||
|
toggle()
|
||||||
|
} else if (cannotUseDevice) {
|
||||||
|
openPermissionsDialog(kind)
|
||||||
|
} else if (explainDeviceInUse) {
|
||||||
|
setAlertError(MediaDeviceFailure.DeviceInUse)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isRequestingPermission.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
useRegisterKeyboardShortcut({
|
||||||
id: deviceShortcut?.id,
|
id: deviceShortcut?.id,
|
||||||
handler: async () => {
|
handler: async () => {
|
||||||
@@ -137,9 +184,42 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
return <ActiveSpeakerWrapper />
|
return <ActiveSpeakerWrapper />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getToggleTooltip = () => {
|
||||||
|
if (deviceMissing) return t(`deviceNotFound.${kind}`)
|
||||||
|
if (explainDeviceInUse) return t(`deviceInUse.${kind}`)
|
||||||
|
if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||||
|
return toggleLabel
|
||||||
|
}
|
||||||
|
const toggleTooltip = getToggleTooltip()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
{cannotUseDevice && <PermissionNeededButton />}
|
{(cannotUseDevice || deviceMissing) && (
|
||||||
|
<PermissionNeededButton
|
||||||
|
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||||
|
onPress={
|
||||||
|
deviceMissing
|
||||||
|
? () => setAlertError(MediaDeviceFailure.NotFound)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deviceInUse && (
|
||||||
|
<PermissionNeededButton
|
||||||
|
tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : undefined}
|
||||||
|
onPress={
|
||||||
|
explainDeviceInUse
|
||||||
|
? () => setAlertError(MediaDeviceFailure.DeviceInUse)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{silentMicWarning && (
|
||||||
|
<PermissionNeededButton
|
||||||
|
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
|
||||||
|
onPress={openSilentMicDialog}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
isSelected={!enabled}
|
isSelected={!enabled}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
@@ -147,23 +227,19 @@ 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={toggleTooltip}
|
||||||
cannotUseDevice
|
|
||||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
|
||||||
: toggleLabel
|
|
||||||
}
|
|
||||||
{...computedToggleButtonProps}
|
{...computedToggleButtonProps}
|
||||||
{...overrideToggleButtonProps}
|
{...overrideToggleButtonProps}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
|
<MediaDeviceErrorAlert
|
||||||
|
error={alertError}
|
||||||
|
kind={kind}
|
||||||
|
onClose={() => setAlertError(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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' })
|
||||||
@@ -15,10 +16,10 @@ export const LeaveButton = () => {
|
|||||||
tooltip={t('leave')}
|
tooltip={t('leave')}
|
||||||
aria-label={t('leave')}
|
aria-label={t('leave')}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
room
|
room.disconnect(true).catch((e) =>
|
||||||
.disconnect(true)
|
reportError('disconnect_failure', e, {
|
||||||
.catch((e) =>
|
context: 'An error occurred while disconnecting:',
|
||||||
console.error('An error occurred while disconnecting:', e)
|
})
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
data-attr="controls-leave"
|
data-attr="controls-leave"
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ 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,
|
||||||
LIGHT = 5,
|
LIGHT = 10,
|
||||||
NORMAL = 10,
|
NORMAL = 20,
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSupported = BackgroundProcessorFactory.isSupported()
|
const isSupported = BackgroundProcessorFactory.isSupported()
|
||||||
@@ -238,7 +239,9 @@ export const EffectsConfiguration = ({
|
|||||||
|
|
||||||
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
|
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error applying effect:', error)
|
reportError('effects_processor_failure', 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,6 +5,7 @@ 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
|
||||||
@@ -55,7 +56,9 @@ export const FunnyEffects = ({
|
|||||||
await videoTrack.setProcessor(newProcessor)
|
await videoTrack.setProcessor(newProcessor)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('could not update processor', e)
|
reportError('effects_processor_failure', e, {
|
||||||
|
context: 'could not update processor',
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
onPending(false)
|
onPending(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
|||||||
isMicrophonePrompted,
|
isMicrophonePrompted,
|
||||||
isCameraDenied,
|
isCameraDenied,
|
||||||
isCameraPrompted,
|
isCameraPrompted,
|
||||||
|
microphoneSystemDenied,
|
||||||
|
cameraSystemDenied,
|
||||||
} = useSnapshot(permissionsStore)
|
} = useSnapshot(permissionsStore)
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
@@ -17,9 +19,11 @@ 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 isMicrophoneDenied || isMicrophonePrompted
|
return (
|
||||||
|
isMicrophoneDenied || isMicrophonePrompted || microphoneSystemDenied
|
||||||
|
)
|
||||||
case 'videoinput':
|
case 'videoinput':
|
||||||
return isCameraDenied || isCameraPrompted
|
return isCameraDenied || isCameraPrompted || cameraSystemDenied
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -31,5 +35,7 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
|||||||
isMicrophonePrompted,
|
isMicrophonePrompted,
|
||||||
isCameraDenied,
|
isCameraDenied,
|
||||||
isCameraPrompted,
|
isCameraPrompted,
|
||||||
|
microphoneSystemDenied,
|
||||||
|
cameraSystemDenied,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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
|
||||||
|
|
||||||
@@ -58,7 +59,9 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
|||||||
await navigator.clipboard.writeText(content)
|
await navigator.clipboard.writeText(content)
|
||||||
setIsCopied(true)
|
setIsCopied(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
reportError('clipboard_failure', error, {
|
||||||
|
context: 'copy_room_content',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +70,9 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
|||||||
await navigator.clipboard.writeText(roomUrl)
|
await navigator.clipboard.writeText(roomUrl)
|
||||||
setIsRoomUrlCopied(true)
|
setIsRoomUrlCopied(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
reportError('clipboard_failure', error, {
|
||||||
|
context: 'copy_room_url',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
|
||||||
|
import { useCannotUseDevice } from './useCannotUseDevice'
|
||||||
|
import { useDeviceMissing } from './useDeviceMissing'
|
||||||
|
|
||||||
|
export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
|
||||||
|
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
||||||
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
|
const deviceMissing = useDeviceMissing(kind)
|
||||||
|
|
||||||
|
if (cannotUseDevice || deviceMissing) return false
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'videoinput':
|
||||||
|
return cameraInUse
|
||||||
|
case 'audioinput':
|
||||||
|
return microphoneInUse
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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,6 +4,7 @@
|
|||||||
|
|
||||||
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,
|
||||||
@@ -56,7 +57,9 @@ export function useFullScreen({
|
|||||||
await docEl.msRequestFullscreen()
|
await docEl.msRequestFullscreen()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error entering fullscreen:', error)
|
reportError('fullscreen_failure', error, {
|
||||||
|
context: 'Error entering fullscreen:',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +73,9 @@ export function useFullScreen({
|
|||||||
await document.msExitFullscreen()
|
await document.msExitFullscreen()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error exiting fullscreen:', error)
|
reportError('fullscreen_failure', error, {
|
||||||
|
context: 'Error exiting fullscreen:',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
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 {
|
||||||
|
isSystemPermissionError,
|
||||||
|
type PermissionKind,
|
||||||
|
} from '@/stores/permissions'
|
||||||
|
import {
|
||||||
|
noteDeviceReady,
|
||||||
|
onMediaPermissionError,
|
||||||
|
} from '../utils/mediaPermissions'
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
noteDeviceReady()
|
||||||
|
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)
|
||||||
|
noteDeviceReady('microphone')
|
||||||
|
})
|
||||||
|
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
||||||
|
.finally(() =>
|
||||||
|
setState((current) => ({ ...current, audioReady: true }))
|
||||||
|
)
|
||||||
|
void navigator.mediaDevices
|
||||||
|
.getUserMedia({ video: true })
|
||||||
|
.then((stream) => {
|
||||||
|
stopAll(stream)
|
||||||
|
noteDeviceReady('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) => {
|
||||||
|
noteDeviceReady(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,6 +1,7 @@
|
|||||||
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()
|
||||||
@@ -28,7 +29,9 @@ export const usePermissionsManager = () => {
|
|||||||
|
|
||||||
return { configuration: newConfiguration }
|
return { configuration: newConfiguration }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update muting permission:', error)
|
reportError('permissions_api_failure', error, {
|
||||||
|
context: 'Failed to update muting permission:',
|
||||||
|
})
|
||||||
return { success: false, error }
|
return { success: false, error }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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[],
|
||||||
@@ -108,7 +109,9 @@ export const usePublishSourcesManager = () => {
|
|||||||
|
|
||||||
return { configuration: newConfiguration }
|
return { configuration: newConfiguration }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to update ${sources}:`, error)
|
reportError('publish_sources_failure', error, {
|
||||||
|
context: `Failed to update ${sources}:`,
|
||||||
|
})
|
||||||
return { success: false, error }
|
return { success: false, error }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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
|
||||||
@@ -79,9 +80,9 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
|
|||||||
try {
|
try {
|
||||||
await raiseHand(!isHandRaised)
|
await raiseHand(!isHandRaised)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
reportError('generic_failure', e, {
|
||||||
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}`
|
context: 'toggle_raised_hand',
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
export const useResolveInitiallyDefaultDeviceId = <
|
|
||||||
T extends { getDeviceId(): Promise<string | undefined> },
|
|
||||||
>(
|
|
||||||
currentId: string,
|
|
||||||
track: T | undefined,
|
|
||||||
save: (id: string) => void
|
|
||||||
) => {
|
|
||||||
const isInitiated = useRef(false)
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentId !== 'default' || !track || isInitiated.current) return
|
|
||||||
const resolveDefaultDeviceId = async () => {
|
|
||||||
const actualDeviceId = await track.getDeviceId()
|
|
||||||
if (actualDeviceId && actualDeviceId !== 'default') {
|
|
||||||
isInitiated.current = true
|
|
||||||
save(actualDeviceId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resolveDefaultDeviceId()
|
|
||||||
}, [currentId, track, save])
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,18 @@
|
|||||||
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 name to help users easily find
|
* Updates the browser tab title with the room id 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 = () => {
|
export const useRoomPageTitle = (roomId?: string) => {
|
||||||
const roomData = useRoomData()
|
|
||||||
|
|
||||||
const pageTitle = useMemo(() => {
|
const pageTitle = useMemo(() => {
|
||||||
if (!roomData) {
|
if (!roomId) return APP_TITLE
|
||||||
return APP_TITLE
|
return `${APP_TITLE} - ${roomId}`
|
||||||
}
|
}, [roomId])
|
||||||
|
|
||||||
const roomLabel = roomData.name || roomData.slug || ''
|
|
||||||
|
|
||||||
if (!roomLabel) return APP_TITLE
|
|
||||||
|
|
||||||
return `${APP_TITLE} - ${roomLabel} `
|
|
||||||
}, [roomData])
|
|
||||||
|
|
||||||
useTitle(pageTitle)
|
useTitle(pageTitle)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import {
|
||||||
|
type LocalTrackPublication,
|
||||||
|
MediaDeviceFailure,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
} from 'livekit-client'
|
||||||
|
import {
|
||||||
|
PERMISSION_BY_DEVICE_KIND,
|
||||||
|
type PermissionDeniedScope,
|
||||||
|
type PermissionKind,
|
||||||
|
classifyPermissionError,
|
||||||
|
isLikelySystemNotFound,
|
||||||
|
notePermissionDeniedFromGum,
|
||||||
|
noteSystemPermissionDenied,
|
||||||
|
} from '@/stores/permissions'
|
||||||
|
import {
|
||||||
|
clearDeviceInUse,
|
||||||
|
noteDeviceInUse,
|
||||||
|
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 PERMISSION_BY_SOURCE: Partial<Record<Track.Source, PermissionKind>> = {
|
||||||
|
[Track.Source.Camera]: 'camera',
|
||||||
|
[Track.Source.Microphone]: 'microphone',
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
noteDeviceInUse(permissionKind)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onTrackPublished = (publication: LocalTrackPublication) => {
|
||||||
|
const permissionKind = PERMISSION_BY_SOURCE[publication.source]
|
||||||
|
if (permissionKind) clearDeviceInUse(permissionKind)
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.MediaDevicesError, onDeviceError)
|
||||||
|
room.on(RoomEvent.LocalTrackPublished, onTrackPublished)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.MediaDevicesError, onDeviceError)
|
||||||
|
room.off(RoomEvent.LocalTrackPublished, onTrackPublished)
|
||||||
|
clearDeviceInUse()
|
||||||
|
}
|
||||||
|
}, [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 { useRef } from 'react'
|
import { RefObject, useMemo, useState } 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,7 +21,14 @@ 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({})
|
||||||
|
|
||||||
@@ -45,7 +52,7 @@ export function DesktopControlBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={desktopControlBarEl}
|
ref={setControlBarElement}
|
||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ 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 = 1100
|
const CONTROL_BAR_BREAKPOINT_WIDE = 1100
|
||||||
|
const CONTROL_BAR_BREAKPOINT_NARROW = 1050
|
||||||
|
|
||||||
const NavigationControls = ({
|
const NavigationControls = ({
|
||||||
onPress,
|
onPress,
|
||||||
@@ -65,10 +66,9 @@ export const LateralMenu = () => {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BreakpointObserverProps {
|
interface BreakpointObserverProps {
|
||||||
containerRef: RefObject<HTMLDivElement>
|
containerRef: RefObject<HTMLDivElement>
|
||||||
onWideChange: (isWide: boolean) => void
|
onWideChange: (isWide: boolean | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const BreakpointObserver = ({
|
const BreakpointObserver = ({
|
||||||
@@ -76,7 +76,20 @@ const BreakpointObserver = ({
|
|||||||
onWideChange,
|
onWideChange,
|
||||||
}: BreakpointObserverProps) => {
|
}: BreakpointObserverProps) => {
|
||||||
const { width } = useSize(containerRef)
|
const { width } = useSize(containerRef)
|
||||||
const isWide = width > CONTROL_BAR_BREAKPOINT
|
const [isWide, setIsWide] = useState<boolean | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!width) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (width > CONTROL_BAR_BREAKPOINT_WIDE) {
|
||||||
|
setIsWide(true)
|
||||||
|
} else if (width <= CONTROL_BAR_BREAKPOINT_NARROW) {
|
||||||
|
setIsWide(false)
|
||||||
|
} else {
|
||||||
|
setIsWide((prev) => (prev === null ? false : prev))
|
||||||
|
}
|
||||||
|
}, [width])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onWideChange(isWide)
|
onWideChange(isWide)
|
||||||
@@ -90,7 +103,7 @@ export const MoreOptions = ({
|
|||||||
}: {
|
}: {
|
||||||
parentElement: RefObject<HTMLDivElement>
|
parentElement: RefObject<HTMLDivElement>
|
||||||
}) => {
|
}) => {
|
||||||
const [isWide, setIsWide] = useState(false)
|
const [isWide, setIsWide] = useState<boolean | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -107,7 +120,7 @@ export const MoreOptions = ({
|
|||||||
containerRef={parentElement}
|
containerRef={parentElement}
|
||||||
onWideChange={setIsWide}
|
onWideChange={setIsWide}
|
||||||
/>
|
/>
|
||||||
{isWide ? <NavigationControls /> : <LateralMenu />}
|
{isWide !== null && (isWide ? <NavigationControls /> : <LateralMenu />)}
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isWeb } from '@livekit/components-core'
|
import { isWeb } from '@livekit/components-core'
|
||||||
import { Track } from 'livekit-client'
|
import { MediaDeviceFailure, Track } from 'livekit-client'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ConnectionStateToast,
|
ConnectionStateToast,
|
||||||
@@ -11,12 +11,15 @@ 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'
|
||||||
|
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
|
||||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||||
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
||||||
@@ -25,6 +28,8 @@ 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
|
||||||
@@ -34,6 +39,20 @@ 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
|
||||||
@@ -53,17 +72,51 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
|
|||||||
* @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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MediaDeviceFailure.getFailure(error) != MediaDeviceFailure.Other) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reportError('device_switch_failure', error, {
|
||||||
|
at: 'ControlBar.onDeviceError',
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RoomMetadataSynchronizer />
|
<RoomMetadataSynchronizer />
|
||||||
<ConnectionObserver />
|
<ConnectionObserver />
|
||||||
|
<SyncDevicePreferences />
|
||||||
|
<RoomSilentMicDetector />
|
||||||
<MediaStateObserver />
|
<MediaStateObserver />
|
||||||
<ChatProvider />
|
<ChatProvider />
|
||||||
<VideoResolutionSubscription />
|
<VideoResolutionSubscription />
|
||||||
@@ -89,18 +142,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<StageLayout />
|
<StageLayout />
|
||||||
)}
|
)}
|
||||||
</RoomContentArea>
|
</RoomContentArea>
|
||||||
<ControlBar
|
<ControlBar onDeviceError={handleDeviceError} />
|
||||||
onDeviceError={(e) => {
|
|
||||||
console.error(e)
|
|
||||||
if (
|
|
||||||
e.source == Track.Source.ScreenShare &&
|
|
||||||
e.error.toString() ==
|
|
||||||
'NotAllowedError: Permission denied by system'
|
|
||||||
) {
|
|
||||||
setIsShareErrorVisible(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<SidePanel />
|
<SidePanel />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -108,6 +150,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<ConnectionStateToast />
|
<ConnectionStateToast />
|
||||||
<RecordingProvider />
|
<RecordingProvider />
|
||||||
<SettingsDialogProvider />
|
<SettingsDialogProvider />
|
||||||
|
<MuteAlertDialogProvider />
|
||||||
<ReactionPortals />
|
<ReactionPortals />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
createLocalAudioTrack,
|
||||||
|
createLocalVideoTrack,
|
||||||
|
MediaDeviceFailure,
|
||||||
|
} from 'livekit-client'
|
||||||
|
import {
|
||||||
|
classifyPermissionError,
|
||||||
|
isLikelySystemNotFound,
|
||||||
|
noteGumSuccess,
|
||||||
|
notePermissionDeniedFromGum,
|
||||||
|
noteSystemPermissionDenied,
|
||||||
|
type PermissionKind,
|
||||||
|
} from '@/stores/permissions'
|
||||||
|
import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceAvailability'
|
||||||
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
|
import { getOS } from '@/utils/os'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared handling of getUserMedia() outcomes, used by both:
|
||||||
|
* - the join preview (`useJoinTracks`: warmup + local track acquisition)
|
||||||
|
* - the in-room device toggle (`ToggleDevice`, when permission is missing)
|
||||||
|
*/
|
||||||
|
export type MediaPath = 'join_preview' | 'room'
|
||||||
|
|
||||||
|
export const PERMISSION_KIND: Record<
|
||||||
|
'audioinput' | 'videoinput',
|
||||||
|
PermissionKind
|
||||||
|
> = {
|
||||||
|
audioinput: 'microphone',
|
||||||
|
videoinput: 'camera',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const noteDeviceReady = (kind?: PermissionKind) => {
|
||||||
|
noteGumSuccess(kind)
|
||||||
|
clearDeviceInUse(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onMediaPermissionError = (
|
||||||
|
e: Error,
|
||||||
|
kind?: PermissionKind,
|
||||||
|
path: MediaPath = 'join_preview'
|
||||||
|
) => {
|
||||||
|
const failure = MediaDeviceFailure.getFailure(e)
|
||||||
|
|
||||||
|
if (failure === 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 (failure === 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failure === MediaDeviceFailure.DeviceInUse) {
|
||||||
|
noteDeviceInUse(kind)
|
||||||
|
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Other" is still reported as an error.
|
||||||
|
reportError(
|
||||||
|
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
||||||
|
e,
|
||||||
|
{ path, kind }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silent availability check for a device that was reported "in use":
|
||||||
|
* acquires and releases it without any error reporting, so it can be
|
||||||
|
* polled. Clears the in-use flag on success.
|
||||||
|
*/
|
||||||
|
export const probeDeviceReleased = async (
|
||||||
|
kind: PermissionKind
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia(
|
||||||
|
kind === 'camera' ? { video: true } : { audio: true }
|
||||||
|
)
|
||||||
|
stream.getTracks().forEach((track) => track.stop())
|
||||||
|
noteDeviceReady(kind)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers the browser permission prompt for one device kind by acquiring
|
||||||
|
* and immediately releasing a track. Resolves to whether access was granted.
|
||||||
|
*/
|
||||||
|
export const requestDevicePermission = async (
|
||||||
|
kind: 'audioinput' | 'videoinput',
|
||||||
|
path: MediaPath = 'join_preview'
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const track =
|
||||||
|
kind === 'audioinput'
|
||||||
|
? await createLocalAudioTrack()
|
||||||
|
: await createLocalVideoTrack()
|
||||||
|
track.stop()
|
||||||
|
noteDeviceReady(PERMISSION_KIND[kind])
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ 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', {
|
||||||
@@ -48,10 +47,6 @@ 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,6 +7,7 @@ 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,
|
||||||
@@ -14,11 +15,15 @@ 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 { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
@@ -36,12 +41,16 @@ 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()
|
||||||
|
useWatchDeviceReleased()
|
||||||
|
|
||||||
const clearRouterState = () => {
|
const clearRouterState = () => {
|
||||||
if (window?.history?.state) {
|
if (window?.history?.state) {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const canTestAudioOutput = () =>
|
||||||
|
typeof HTMLMediaElement !== 'undefined' &&
|
||||||
|
'setSinkId' in HTMLMediaElement.prototype // Safari: no output routing
|
||||||
@@ -5,6 +5,7 @@ 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()
|
||||||
@@ -52,7 +53,9 @@ const CreatePopup = () => {
|
|||||||
popupWindow.close()
|
popupWindow.close()
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create meeting room:', error)
|
reportError('generic_failure', error, {
|
||||||
|
context: 'Failed to create meeting room:',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isLoggedIn && callbackId) {
|
if (isLoggedIn && callbackId) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ const SettingsPopup = () => {
|
|||||||
patchRoom({
|
patchRoom({
|
||||||
roomId: roomSlug,
|
roomId: roomSlug,
|
||||||
room: { configuration: newConfiguration },
|
room: { configuration: newConfiguration },
|
||||||
}).catch((e) => console.error(e))
|
}).catch((e) => reportError('generic_failure', e))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSource = (sources: Source[], enabled: boolean) => {
|
const updateSource = (sources: Source[], enabled: boolean) => {
|
||||||
@@ -329,7 +330,7 @@ const SettingsPopup = () => {
|
|||||||
patchRoom({
|
patchRoom({
|
||||||
roomId: roomSlug,
|
roomId: roomSlug,
|
||||||
room: { access_level: value as ApiAccessLevel },
|
room: { access_level: value as ApiAccessLevel },
|
||||||
}).catch((e) => console.error(e))
|
}).catch((e) => reportError('generic_failure', e))
|
||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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(
|
||||||
@@ -9,7 +10,7 @@ export class PopupWindow {
|
|||||||
callback?: () => void
|
callback?: () => void
|
||||||
) {
|
) {
|
||||||
if (!window.opener) {
|
if (!window.opener) {
|
||||||
console.error('No manager window found')
|
reportError('generic_failure', new Error('No manager window found'))
|
||||||
window.close()
|
window.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
|
|||||||
import { saveUsername } from '@/stores/user'
|
import { saveUsername } from '@/stores/user'
|
||||||
import { logout } from '@/features/auth/utils/logout'
|
import { logout } from '@/features/auth/utils/logout'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||||
Pick<TabPanelProps, 'id'>
|
Pick<TabPanelProps, 'id'>
|
||||||
@@ -35,9 +36,9 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
|||||||
saveUsername(name)
|
saveUsername(name)
|
||||||
onOpenChange?.(false) // only close on success
|
onOpenChange?.(false) // only close on success
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
reportError('generic_failure', error, {
|
||||||
`Failed to rename participant: ${error instanceof Error ? error.message : 'Unknown error'}`
|
context: 'rename_participant',
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const handleOnCancel = () => {
|
const handleOnCancel = () => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { SoundTester } from '@/components/SoundTester'
|
import { SoundTester } from '@/components/SoundTester'
|
||||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||||
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
|
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { RowWrapper } from './layout/RowWrapper'
|
import { RowWrapper } from './layout/RowWrapper'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +19,7 @@ import {
|
|||||||
saveNoiseReductionEnabled,
|
saveNoiseReductionEnabled,
|
||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
|
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||||
Pick<TabPanelProps, 'id'>
|
Pick<TabPanelProps, 'id'>
|
||||||
@@ -128,7 +128,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
|||||||
isSelected={noiseReductionEnabled}
|
isSelected={noiseReductionEnabled}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
saveNoiseReductionEnabled(v)
|
saveNoiseReductionEnabled(v)
|
||||||
if (v) posthog.capture('noise-reduction-init')
|
if (v) captureEvent('noise-reduction-init')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('audio.noiseReduction.label')}
|
{t('audio.noiseReduction.label')}
|
||||||
|
|||||||
@@ -169,7 +169,13 @@ export const Header = () => {
|
|||||||
!isTermsOfService &&
|
!isTermsOfService &&
|
||||||
!loginButtonDisabledByUrl && (
|
!loginButtonDisabledByUrl && (
|
||||||
<>
|
<>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: { base: 'none', xsm: 'block' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
<LoginButton proConnectHint={false} />
|
<LoginButton proConnectHint={false} />
|
||||||
|
</div>
|
||||||
<LoginHint />
|
<LoginHint />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,14 @@
|
|||||||
"loading": "Laden…",
|
"loading": "Laden…",
|
||||||
"select": "Wähle einen Wert",
|
"select": "Wähle einen Wert",
|
||||||
"permissionsNeeded": "Berechtigung erforderlich",
|
"permissionsNeeded": "Berechtigung erforderlich",
|
||||||
|
"deviceNotFound": {
|
||||||
|
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||||
|
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
||||||
|
},
|
||||||
|
"deviceInUse": {
|
||||||
|
"videoinput": "Kamera nicht verfügbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
||||||
|
"audioinput": "Mikrofon nicht verfügbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet."
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audioeinstellungen",
|
"audio": "Audioeinstellungen",
|
||||||
"video": "Videoeinstellungen"
|
"video": "Videoeinstellungen"
|
||||||
@@ -29,6 +37,8 @@
|
|||||||
},
|
},
|
||||||
"audioinput": {
|
"audioinput": {
|
||||||
"choose": "Mikrofon auswählen",
|
"choose": "Mikrofon auswählen",
|
||||||
|
"level": "Mikrofon-Eingangspegel",
|
||||||
|
"muteTest": "Aktivieren Sie Ihr Mikrofon, um es zu testen",
|
||||||
"permissionsNeeded": "Mikrofon auswählen – Berechtigung erforderlich",
|
"permissionsNeeded": "Mikrofon auswählen – Berechtigung erforderlich",
|
||||||
"disable": "Mikrofon deaktivieren",
|
"disable": "Mikrofon deaktivieren",
|
||||||
"enable": "Mikrofon aktivieren",
|
"enable": "Mikrofon aktivieren",
|
||||||
@@ -38,7 +48,9 @@
|
|||||||
},
|
},
|
||||||
"audiooutput": {
|
"audiooutput": {
|
||||||
"choose": "Audioausgabe auswählen",
|
"choose": "Audioausgabe auswählen",
|
||||||
"permissionsNeeded": "Audioausgabe auswählen – Berechtigung erforderlich"
|
"permissionsNeeded": "Audioausgabe auswählen – Berechtigung erforderlich",
|
||||||
|
"test": "Lautsprecher testen",
|
||||||
|
"testing": "Testton wird abgespielt…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"join": {
|
"join": {
|
||||||
@@ -58,6 +70,8 @@
|
|||||||
"usernameEmpty": "Dein Name darf nicht leer sein"
|
"usernameEmpty": "Dein Name darf nicht leer sein"
|
||||||
},
|
},
|
||||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||||
|
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||||
|
"cameraInUse": "Deine Kamera ist nicht verfügbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
||||||
"cameraStarting": "Kamera wird gestartet…",
|
"cameraStarting": "Kamera wird gestartet…",
|
||||||
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
||||||
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
||||||
@@ -113,6 +127,16 @@
|
|||||||
"videoinput": "Deine Kamera wird bereits in einem anderen Tab oder von einer anderen App verwendet. Dies verhindert die Aktivierung in diesem Meeting. Wenn du deine Kamera hier verwenden möchtest, schließe den anderen Tab oder die App."
|
"videoinput": "Deine Kamera wird bereits in einem anderen Tab oder von einer anderen App verwendet. Dies verhindert die Aktivierung in diesem Meeting. Wenn du deine Kamera hier verwenden möchtest, schließe den anderen Tab oder die App."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"NotFound": {
|
||||||
|
"title": {
|
||||||
|
"audioinput": "Kein Mikrofon erkannt",
|
||||||
|
"videoinput": "Keine Kamera erkannt"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"audioinput": "Auf deinem Gerät wurde kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist und nicht durch einen Schalter stummgeschaltet wird, und versuche es dann erneut.",
|
||||||
|
"videoinput": "Auf deinem Gerät wurde keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist und nicht durch eine Abdeckung oder einen Schalter blockiert wird, und versuche es dann erneut."
|
||||||
|
}
|
||||||
|
},
|
||||||
"close": "OK"
|
"close": "OK"
|
||||||
},
|
},
|
||||||
"permissionErrorDialog": {
|
"permissionErrorDialog": {
|
||||||
@@ -135,6 +159,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"systemPermissionDialog": {
|
||||||
|
"heading": {
|
||||||
|
"camera": "Erlauben Sie Ihrem Browser, Ihre Kamera zu verwenden",
|
||||||
|
"microphone": "Erlauben Sie Ihrem Browser, Ihr Mikrofon zu verwenden",
|
||||||
|
"cameraAndMicrophone": "Erlauben Sie Ihrem Browser, Ihre Kamera und Ihr Mikrofon zu verwenden"
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"camera": "Kamera",
|
||||||
|
"microphone": "Mikrofon",
|
||||||
|
"cameraAndMicrophone": "Kamera und Ihr Mikrofon"
|
||||||
|
},
|
||||||
|
"intro": "Ihr Browser hat bereits die Berechtigung, aber die Systemeinstellungen Ihres Computers blockieren den Zugriff auf Ihr(e) {{device}}.",
|
||||||
|
"steps": {
|
||||||
|
"macos": {
|
||||||
|
"1": "Öffnen Sie in den Systemeinstellungen „Datenschutz & Sicherheit“.",
|
||||||
|
"2": "Erlauben Sie Ihrem Browser den Zugriff auf Ihr(e) {{device}}."
|
||||||
|
},
|
||||||
|
"windows": {
|
||||||
|
"1": "Öffnen Sie in den Windows-Einstellungen „Datenschutz und Sicherheit“.",
|
||||||
|
"2": "Aktivieren Sie den Zugriff auf {{device}} und erlauben Sie Desktop-Apps die Nutzung."
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"1": "Öffnen Sie in den Android-Einstellungen „Apps“ und wählen Sie Ihren Browser aus.",
|
||||||
|
"2": "Erlauben Sie unter „Berechtigungen“ den Zugriff auf Ihr(e) {{device}} und laden Sie die Seite anschließend neu."
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"1": "Erlauben Sie Ihrem Browser in den Datenschutzeinstellungen Ihres Systems den Zugriff auf Ihr(e) {{device}}.",
|
||||||
|
"2": "Versuchen Sie es anschließend erneut."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openSettings": "Systemeinstellungen öffnen"
|
||||||
|
},
|
||||||
|
"silentMic": {
|
||||||
|
"tooltip": "Ihr Mikrofon ist aktiviert, aber es wird kein Ton erkannt. Klicken Sie für weitere Informationen.",
|
||||||
|
"dialog": {
|
||||||
|
"title": "Kein Ton erkannt",
|
||||||
|
"intro": "Ihr Mikrofon ist aktiviert, aber seit einiger Zeit wird kein Ton erkannt. Überprüfen Sie Folgendes:",
|
||||||
|
"causes": {
|
||||||
|
"system": "Der Zugriff auf das Mikrofon wird möglicherweise durch die Datenschutzeinstellungen Ihres Systems blockiert.",
|
||||||
|
"hardware": "Möglicherweise ist ein Stummschalter aktiviert oder das Mikrofon ist nicht angeschlossen oder defekt.",
|
||||||
|
"wrongDevice": "Möglicherweise ist das falsche Mikrofon ausgewählt. Wählen Sie im Mikrofonmenü ein anderes aus."
|
||||||
|
},
|
||||||
|
"hint": "Sprechen Sie erneut, nachdem Sie diese Punkte überprüft haben. Die Warnung verschwindet, sobald ein Ton erkannt wird.",
|
||||||
|
"close": "Verstanden",
|
||||||
|
"discard": "Erkennung eines stummen Mikrofons deaktivieren"
|
||||||
|
}
|
||||||
|
},
|
||||||
"permissionsButton": {
|
"permissionsButton": {
|
||||||
"tooltip": "Mehr Infos",
|
"tooltip": "Mehr Infos",
|
||||||
"ariaLabel": "Problem mit Berechtigungen. Mehr Infos anzeigen"
|
"ariaLabel": "Problem mit Berechtigungen. Mehr Infos anzeigen"
|
||||||
@@ -148,8 +219,11 @@
|
|||||||
"title": "Bildschirmfreigabe nicht möglich",
|
"title": "Bildschirmfreigabe nicht möglich",
|
||||||
"ariaLabel": "Bildschirmfreigabe nicht möglich",
|
"ariaLabel": "Bildschirmfreigabe nicht möglich",
|
||||||
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
|
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
|
||||||
"macInstructions": "Gehe zu den",
|
"settingsInstructions": "Gehe zu den",
|
||||||
"macSystemPreferences": "Systemeinstellungen",
|
"settingsLabel": {
|
||||||
|
"macos": "Systemeinstellungen",
|
||||||
|
"windows": "Windows-Datenschutzeinstellungen"
|
||||||
|
},
|
||||||
"helpLinkText": "Weitere Informationen findest du unter",
|
"helpLinkText": "Weitere Informationen findest du unter",
|
||||||
"helpLinkLabel": "Bildschirmfreigabeproblem",
|
"helpLinkLabel": "Bildschirmfreigabeproblem",
|
||||||
"closeButton": "Schließen",
|
"closeButton": "Schließen",
|
||||||
@@ -326,7 +400,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"faceLandmarks": {
|
"faceLandmarks": {
|
||||||
"title": "Visuelle Effekte",
|
"title": "Visuelle Effekte",
|
||||||
"glasses": {
|
"glasses": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user