mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-15 13:13:56 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ee27b175b | |||
| 162d663ee2 | |||
| 726acb0e66 | |||
| 062dc32400 | |||
| 1270885132 | |||
| 11c331dfa7 | |||
| 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 | |||
| e0ff28ed48 | |||
| 61e8b597dc | |||
| f3626a2dc6 |
@@ -8,6 +8,60 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.27.0] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔥(frontend) drop unused vendored ConnectionObserver
|
||||
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📈(frontend) downgrade unreachable external home URL from error to event
|
||||
- 🐛(frontend) handle 401 responses when syncing user preferences
|
||||
- 🐛(frontend) harden speaker test against missing sinks and play errors
|
||||
- 🐛(frontend) implement hysteresis band for the control bar layout
|
||||
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
|
||||
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
|
||||
- 🐛(frontend) stop reporting screen-share denials as errors
|
||||
- 🐛(frontend) generalize screen-share error modal beyond macOS
|
||||
- 📈(frontend) stop double-reporting media device failures
|
||||
|
||||
## [1.26.0] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- 📈(frontend) capture media diagnostics on media errors
|
||||
- ✨(frontend) add an audio gauge to the microphone select menu
|
||||
- ✨(frontend) add a sound tester to the output select menu
|
||||
- ✨(frontend) prompt for permissions when toggling a denied device
|
||||
- ⚗️(frontend) capture console.error in PostHog
|
||||
- 📈(frontend) snapshot media devices on the happy path
|
||||
- 🚸(frontend) guide users when the OS blocks browser media access
|
||||
- ✨(frontend) add a silent-microphone watcher on join and room screens
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) encapsulate error tracking behind a telemetry module
|
||||
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
|
||||
- 🔧(frontend) sync persisted device ids with the actual selected devices
|
||||
- 💄(frontend) hide the ProConnect button on narrow viewports
|
||||
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
|
||||
- 🐛(frontend) fix permission store regression
|
||||
- 🐛(frontend) handle missing device errors gracefully
|
||||
- 🐛(frontend) display the meeting id in the join screen page title
|
||||
|
||||
## [1.25.2] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) serve MediaPipe assets under a versioned path
|
||||
- 🐛(frontend) harmonize cache configuration for MediaPipe assets
|
||||
|
||||
## [1.25.1] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -65,6 +65,11 @@ server {
|
||||
sub_filter_once off;
|
||||
}
|
||||
|
||||
location ^~ /assets/mediapipe/wasm/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.25.1"
|
||||
version = "1.27.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.25.1"
|
||||
version = "1.27.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -18,6 +18,7 @@ class FeatureFlag:
|
||||
"application": "APPLICATION_ENABLED",
|
||||
"roomkit": "ROOMKIT_ENABLED",
|
||||
"connection_test": "CONNECTION_TEST_ENABLED",
|
||||
"user_access_token": "USER_ACCESS_TOKEN_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -292,6 +292,11 @@ class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True)
|
||||
participant_id = serializers.UUIDField(required=False, allow_null=True)
|
||||
|
||||
def validate_participant_id(self, value):
|
||||
"""The id is a bearer credential: never trusted, only looked up."""
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||
@@ -599,3 +604,25 @@ class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||
# useless bad requests
|
||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
|
||||
|
||||
class TransitCodeSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate the single-use transit code sent to the exchange endpoint."""
|
||||
|
||||
# todo if I can pass the max length directly to the char field
|
||||
code = serializers.CharField(max_length=255, trim_whitespace=True)
|
||||
|
||||
def validate_code(self, value):
|
||||
"""Reject codes whose length cannot match a generated one.
|
||||
|
||||
`secrets.token_urlsafe(nbytes)` produces (4 * nbytes + 2) // 3
|
||||
url-safe characters. Checking the length against the configured
|
||||
TRANSIT_CODE_NBYTES makes malformed codes fail fast with a 400,
|
||||
before any cache lookup.
|
||||
"""
|
||||
expected_length = (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||
|
||||
if len(value) != expected_length:
|
||||
raise serializers.ValidationError("Invalid transit code format.")
|
||||
|
||||
return value
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Throttling modules for the API."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
from . import serializers
|
||||
|
||||
|
||||
def sentry_monitoring_throttle_failure(message):
|
||||
"""Log when a failure occurs to detect rate limiting issues."""
|
||||
@@ -42,13 +42,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
def get_cache_key(self, request, view):
|
||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
||||
|
||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
||||
return None to skip throttling — the cookie will be set on the first
|
||||
response, and throttling will apply from the second request onward.
|
||||
Only throttle requests carrying a participant identifier. The
|
||||
identifier is returned by the first request-entry response and
|
||||
echoed back by the client from the second request onward, which is
|
||||
when throttling starts applying.
|
||||
|
||||
Keying on the cookie rather than the IP address prevents penalising
|
||||
multiple users behind the same NAT/proxy, and is consistent with how
|
||||
LobbyService identifies participants.
|
||||
Keying on the identifier rather than the IP address prevents
|
||||
penalising multiple users behind the same NAT/proxy, and is
|
||||
consistent with how the lobby identifies participants.
|
||||
|
||||
Note: as per DRF documentation, application-level throttling is not a
|
||||
security measure against brute-force or DoS attacks. This throttle exists
|
||||
@@ -58,10 +59,14 @@ class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
if request.user and request.user.is_authenticated:
|
||||
return None # Only throttle unauthenticated requests.
|
||||
|
||||
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
||||
serializer = serializers.RequestEntrySerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return None
|
||||
|
||||
if participant_id is None:
|
||||
return None # No throttling for cookieless requests
|
||||
participant_id = serializer.validated_data.get("participant_id")
|
||||
|
||||
if not participant_id:
|
||||
return None # No throttling for unidentified requests
|
||||
|
||||
return self.cache_format % {
|
||||
"scope": self.scope,
|
||||
@@ -97,3 +102,14 @@ class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous users requesting connection test tokens."""
|
||||
|
||||
scope = "connection_test"
|
||||
|
||||
|
||||
class ExchangeAccessTokenAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous transit code exchange attempts.
|
||||
|
||||
Abuse mitigation only, not a security boundary: DRF throttling is
|
||||
best-effort. The security of the exchange rests on the codes'
|
||||
entropy and single use.
|
||||
"""
|
||||
|
||||
scope = "exchange_access_token"
|
||||
|
||||
@@ -75,6 +75,7 @@ from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
from core.services.invitation import InvitationService
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.livekit_events import (
|
||||
LiveKitEventsService,
|
||||
LiveKitWebhookError,
|
||||
@@ -100,6 +101,7 @@ from core.services.room_roles import (
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
from core.services.transit_code import TransitCodeService
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.utils import generate_token
|
||||
|
||||
@@ -237,6 +239,76 @@ class UserViewSet(
|
||||
self.serializer_class(request.user, context=context).data
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="exchange-access-token",
|
||||
permission_classes=[],
|
||||
throttle_classes=[throttling.ExchangeAccessTokenAnonRateThrottle],
|
||||
)
|
||||
@FeatureFlag.require("user_access_token")
|
||||
def exchange_access_token(self, request):
|
||||
"""Exchange a single-use transit code for a user access token.
|
||||
|
||||
The endpoint is unauthenticated: the transit code itself, an opaque
|
||||
random string obtained through the external API and delivered to
|
||||
the embedded frontend via a URL fragment, is the credential. Each
|
||||
code can be exchanged exactly once (consuming it deletes it from
|
||||
the cache); replaying a consumed code is denied and logged.
|
||||
|
||||
The issued JWT authenticates the user the code was minted for on
|
||||
the whole core API, exactly like a session cookie would (similar
|
||||
to lib-jitsi-meet's token authentication), and never appears in
|
||||
any URL. Role-based permissions apply unchanged.
|
||||
"""
|
||||
serializer = serializers.TransitCodeSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
code_data = TransitCodeService().consume_code(serializer.validated_data["code"])
|
||||
|
||||
if code_data is None:
|
||||
logger.warning("Invalid, expired or already used transit code")
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
"Invalid, expired or already used transit code."
|
||||
)
|
||||
|
||||
# Re-check the user at exchange time so that a deactivation after
|
||||
# the transit code was minted is taken into account.
|
||||
try:
|
||||
user = models.User.objects.get(id=code_data["user_id"], is_active=True)
|
||||
except models.User.DoesNotExist as excpt:
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
"This account can no longer access the application."
|
||||
) from excpt
|
||||
|
||||
token_service = JwtTokenService(
|
||||
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||
)
|
||||
|
||||
# todo - discuss wether it's the relevant scope
|
||||
data = token_service.generate_jwt(
|
||||
user,
|
||||
"user:access",
|
||||
{
|
||||
"token_type": "user_access",
|
||||
"client_id": code_data.get("client_id", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"User access token issued from transit code: user_id=%s, client_id=%s",
|
||||
user.id,
|
||||
code_data.get("client_id", "unknown"),
|
||||
)
|
||||
|
||||
return drf_response.Response(data)
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
@@ -526,10 +598,7 @@ class RoomViewSet(
|
||||
request=request,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||
lobby_service.prepare_response(response, participant.id)
|
||||
|
||||
return response
|
||||
return drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
|
||||
@@ -9,6 +9,8 @@ from rest_framework import authentication, exceptions
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
LIVEKIT_AUTH_SCHEME = "X-LiveKit-Token"
|
||||
|
||||
|
||||
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||
@@ -20,9 +22,14 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
return None # No authentication attempted
|
||||
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
if not parts or parts[0].lower() != LIVEKIT_AUTH_SCHEME.lower():
|
||||
# Not our scheme (e.g. "Bearer <user access token>"): defer, another
|
||||
# backend may recognize it.
|
||||
return None
|
||||
|
||||
if len(parts) != 2:
|
||||
raise exceptions.AuthenticationFailed(
|
||||
"Authorization header must be: Bearer <token>"
|
||||
f"Authorization header must be: {LIVEKIT_AUTH_SCHEME} <token>"
|
||||
)
|
||||
|
||||
token = parts[1]
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""User access JWT authentication for the Meet core API.
|
||||
|
||||
Allows an embedded frontend (e.g. rendered in an iframe, where third-party
|
||||
session cookies are blocked) to authenticate requests on the core API with
|
||||
a JWT, obtained by exchanging a single-use transit code (see
|
||||
core.services.transit_code and the users exchange-access-token endpoint)
|
||||
and passed as a Bearer header. The JWT itself never appears in any URL.
|
||||
|
||||
Similar to lib-jitsi-meet's token authentication, the token is bound to a
|
||||
user, not to a resource: once authenticated, the request is treated
|
||||
exactly like a session-authenticated one, and the existing role-based
|
||||
permissions apply unchanged.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import exceptions
|
||||
|
||||
from core.external_api.authentication import BaseJWTAuthentication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_ACCESS_TOKEN_TYPE_CLAIM = "user_access" # noqa: S105
|
||||
|
||||
|
||||
class UserAccessJWTAuthentication(BaseJWTAuthentication):
|
||||
"""JWT authentication for user access tokens.
|
||||
|
||||
Validates user access tokens issued by the users exchange-access-token
|
||||
endpoint and authenticates the user they were issued for. A bearer
|
||||
token that does not verify against the user access token secret is
|
||||
deferred to the next authentication backend; a token that does verify
|
||||
but carries wrong claims is rejected.
|
||||
|
||||
When the feature is disabled (USER_ACCESS_TOKEN_ENABLED=False), the
|
||||
backend is entirely inert: `BaseJWTAuthentication.authenticate`
|
||||
returns None before reading the Authorization header, deferring every
|
||||
request to the next authentication backend.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the backend with user access token settings."""
|
||||
super().__init__(
|
||||
secret_key=settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=settings.USER_ACCESS_TOKEN_ALG,
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
expiration_seconds=settings.USER_ACCESS_TOKEN_TTL,
|
||||
token_type=settings.USER_ACCESS_TOKEN_TYPE,
|
||||
is_enabled=settings.USER_ACCESS_TOKEN_ENABLED,
|
||||
)
|
||||
|
||||
def validate_payload(self, payload):
|
||||
"""Validate the token type and the issuance-audit claim.
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If the token verified against the user
|
||||
access token secret but does not carry the expected claims.
|
||||
"""
|
||||
if payload.get("token_type") != USER_ACCESS_TOKEN_TYPE_CLAIM:
|
||||
logger.warning("Wrong 'token_type' in user access token payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
# Every token we issue carries the client_id of the application the
|
||||
# transit code was minted for: its absence means the token does not
|
||||
# come from the exchange endpoint.
|
||||
if not payload.get("client_id"):
|
||||
logger.warning("Missing 'client_id' in user access token payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
@@ -86,6 +86,14 @@ class HasRequiredRoomScope(BaseScopePermission):
|
||||
}
|
||||
|
||||
|
||||
class HasRequiredUserScope(BaseScopePermission):
|
||||
"""Scope-based permissions for the external user endpoints."""
|
||||
|
||||
scope_map = {
|
||||
"generate_transit_code": models.ApplicationScope.USERS_SESSION,
|
||||
}
|
||||
|
||||
|
||||
class RoomPermissions(permissions.BasePermission):
|
||||
"""Permissions applying to the room API endpoint."""
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from rest_framework import (
|
||||
from core import analytics, api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
@@ -218,3 +219,62 @@ class RoomViewSet(
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class UserViewSet(viewsets.GenericViewSet):
|
||||
"""Application-delegated API for user operations.
|
||||
|
||||
Provides JWT-authenticated access to user operations for external
|
||||
applications acting on behalf of users. All operations are
|
||||
scope-based. Meant to grow with the other user actions exposed to
|
||||
third parties.
|
||||
|
||||
Supported operations:
|
||||
- transit-code: Mint a single-use transit code for the delegated user
|
||||
(requires 'users:session' scope)
|
||||
"""
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
ResourceServerAuthentication,
|
||||
]
|
||||
permission_classes = [
|
||||
api.permissions.IsAuthenticated & permissions.HasRequiredUserScope
|
||||
]
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="transit-code",
|
||||
url_name="transit-code",
|
||||
)
|
||||
@FeatureFlag.require("user_access_token")
|
||||
def generate_transit_code(self, request):
|
||||
"""Mint a transit code for the delegated user.
|
||||
|
||||
Returns a short-lived, single-use opaque code to pass to an embedded
|
||||
frontend (e.g. via a URL fragment when cookies are unavailable). The
|
||||
frontend exchanges it once on
|
||||
POST /api/v1.0/users/exchange-access-token/ for a JWT access token,
|
||||
equivalent to session-cookie authentication and never exposed in a URL.
|
||||
"""
|
||||
auth_method = type(request.successful_authenticator).__name__
|
||||
client_id = (request.auth or {}).get("client_id", "unknown")
|
||||
|
||||
code = TransitCodeService().create_code(request.user, client_id=client_id)
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"Transit code issued: user_id=%s, client_id=%s, auth_method=%s",
|
||||
request.user.id,
|
||||
client_id,
|
||||
auth_method,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"transit_code": code,
|
||||
"expires_in": settings.TRANSIT_CODE_TTL,
|
||||
},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.14 on 2026-07-31 18:27
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='application',
|
||||
name='scopes',
|
||||
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms'), ('users:session', 'Create user session tokens')], max_length=50), blank=True, default=list, size=None),
|
||||
),
|
||||
]
|
||||
@@ -795,6 +795,7 @@ class ApplicationScope(models.TextChoices):
|
||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||
USERS_SESSION = "users:session", _("Create user session tokens")
|
||||
|
||||
|
||||
class Application(BaseModel):
|
||||
|
||||
@@ -86,23 +86,6 @@ class LobbyService:
|
||||
"""Generate cache key for participant(s) data."""
|
||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
|
||||
@staticmethod
|
||||
def _get_or_create_participant_id(request) -> str:
|
||||
"""Extract unique participant identifier from the request."""
|
||||
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
|
||||
|
||||
@staticmethod
|
||||
def prepare_response(response, participant_id):
|
||||
"""Set participant cookie if needed."""
|
||||
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
|
||||
response.set_cookie(
|
||||
key=settings.LOBBY_COOKIE_NAME,
|
||||
value=participant_id,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def can_bypass_lobby(room, user, role) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
@@ -135,6 +118,7 @@ class LobbyService:
|
||||
room: models.Room,
|
||||
request,
|
||||
username: str,
|
||||
participant_id: Optional[uuid.UUID] = None,
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
"""Request entry to a room for a participant.
|
||||
|
||||
@@ -149,22 +133,20 @@ class LobbyService:
|
||||
5. If denied, do nothing.
|
||||
"""
|
||||
|
||||
participant_id = self._get_or_create_participant_id(request)
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
participant = None
|
||||
if participant_id:
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
is_new_participant = participant is None
|
||||
if is_new_participant:
|
||||
participant = self._create_participant(room.id, username)
|
||||
|
||||
room_id = str(room.id)
|
||||
user_role = room.get_role(request.user)
|
||||
|
||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||
if participant is None:
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
self._save_participant(room.id, participant)
|
||||
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
@@ -172,18 +154,18 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
participant_id=participant.id,
|
||||
role=user_role,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
livekit_config = None
|
||||
|
||||
if participant is None:
|
||||
participant = self.enter(room.id, participant_id, username)
|
||||
if is_new_participant:
|
||||
self._notify_entry_request(room_id)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||
self.refresh_waiting_status(room.id, participant_id)
|
||||
self.refresh_waiting_status(room.id, participant.id)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# wrongly named, contains access token to join a room
|
||||
@@ -193,7 +175,7 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
participant_id=participant_id,
|
||||
participant_id=participant.id,
|
||||
role=user_role,
|
||||
)
|
||||
|
||||
@@ -210,27 +192,36 @@ class LobbyService:
|
||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
|
||||
def enter(
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby.
|
||||
def _create_participant(self, room_id: UUID, username: str) -> LobbyParticipant:
|
||||
"""Create and persist a new waiting participant.
|
||||
|
||||
Create a new participant entry in waiting status and notify room
|
||||
participants of the new entry request.
|
||||
Participant identifiers are minted here, server-side, exclusively.
|
||||
"""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
|
||||
participant_id = str(uuid.uuid4())
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=color,
|
||||
color=utils.generate_color(participant_id),
|
||||
)
|
||||
self._save_participant(room_id, participant)
|
||||
|
||||
return participant
|
||||
|
||||
def _save_participant(self, room_id: UUID, participant: LobbyParticipant):
|
||||
"""Persist a participant in the room's lobby."""
|
||||
cache.set(
|
||||
self._get_cache_key(room_id, participant.id),
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_entry_request(room_id: str):
|
||||
"""Notify room participants of a new entry request."""
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(room_id),
|
||||
room_name=room_id,
|
||||
notification_data={
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
},
|
||||
@@ -239,15 +230,6 @@ class LobbyService:
|
||||
# If room not created yet, there is no participants to notify
|
||||
logger.exception("Failed to notify room participants")
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.set(
|
||||
cache_key,
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
|
||||
return participant
|
||||
|
||||
def _get_participant(
|
||||
self, room_id: UUID, participant_id: str
|
||||
) -> Optional[LobbyParticipant]:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Service handling the lifecycle of transit codes.
|
||||
|
||||
A transit code is an opaque, cryptographically random, single-use code
|
||||
handed to an embedded frontend (through a URL fragment) so it can obtain a
|
||||
user access token on the core API without a session cookie. The code
|
||||
carries no information by itself: everything it references (user, client)
|
||||
is stored server-side in the cache, and consumed atomically on exchange.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
class TransitCodeService:
|
||||
"""Create and consume single-use transit codes."""
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(code):
|
||||
"""Build the cache key for a code.
|
||||
|
||||
The code is hashed so that a dump of the cache never reveals
|
||||
directly usable codes.
|
||||
"""
|
||||
digest = hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||
return f"{settings.TRANSIT_CODE_CACHE_PREFIX}:{digest}"
|
||||
|
||||
def create_code(self, user, client_id="unknown"):
|
||||
"""Generate a transit code for a user, and store it.
|
||||
|
||||
The code expires after TRANSIT_CODE_TTL seconds.
|
||||
|
||||
Returns:
|
||||
str: The opaque code to hand to the client.
|
||||
"""
|
||||
# Default 48 random bytes -> 64 url-safe characters, 384 bits of
|
||||
# entropy: unguessable and safe to transit through a URL fragment.
|
||||
code = secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||
|
||||
cache.set(
|
||||
self._cache_key(code),
|
||||
{
|
||||
"user_id": str(user.id),
|
||||
"client_id": client_id,
|
||||
},
|
||||
timeout=settings.TRANSIT_CODE_TTL,
|
||||
)
|
||||
|
||||
return code
|
||||
|
||||
def consume_code(self, code):
|
||||
"""Consume a transit code, enforcing single use.
|
||||
|
||||
The code is deleted from the cache upon consumption. `cache.delete`
|
||||
returns whether a key was actually deleted, so if two requests race
|
||||
on the same code, only one of them wins.
|
||||
|
||||
Returns:
|
||||
dict | None: The data stored at creation time ('user_id',
|
||||
'client_id'), or None if the code is unknown, expired or
|
||||
already consumed.
|
||||
"""
|
||||
if not code:
|
||||
return None
|
||||
|
||||
key = self._cache_key(code)
|
||||
data = cache.get(key)
|
||||
|
||||
if data is None or not cache.delete(key):
|
||||
return None
|
||||
|
||||
return data
|
||||
@@ -2,10 +2,15 @@
|
||||
Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -312,3 +317,38 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_with_user_access_token():
|
||||
"""A user access token should create a room exactly like a session would."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.post("/api/v1.0/rooms/", {"name": "my room"})
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.accesses.filter(role="owner", user=user).exists()
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
Test rooms API endpoints in the Meet core app: list.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
@@ -156,3 +160,40 @@ def test_api_rooms_list_pagination_page_size():
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||
assert content["previous"] is None
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_list_authenticated_with_user_access_token():
|
||||
"""A user access token should list rooms exactly like a session would."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
RoomFactory() # another user's room, not listed
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
@@ -14,9 +14,6 @@ from rest_framework.test import APIClient
|
||||
from ... import utils
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...services.lobby import (
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -29,7 +26,6 @@ def test_request_entry_anonymous(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -47,11 +43,10 @@ def test_request_entry_anonymous(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -78,7 +73,6 @@ def test_request_entry_authenticated_user(settings):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -96,11 +90,10 @@ def test_request_entry_authenticated_user(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -127,7 +120,6 @@ def test_request_entry_with_existing_participants(settings):
|
||||
client = APIClient()
|
||||
|
||||
# Configure test settings for cookies and cache
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add two participants already waiting in the lobby
|
||||
@@ -168,11 +160,10 @@ def test_request_entry_with_existing_participants(settings):
|
||||
# Verify successful response
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set for the new participant
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
# The participant identifier is returned in the response body; no
|
||||
# cookie is involved anymore
|
||||
assert not response.cookies
|
||||
participant_id = response.json()["id"]
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
@@ -197,7 +188,6 @@ def test_request_entry_public_room(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -206,9 +196,7 @@ def test_request_entry_public_room(settings):
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
),
|
||||
mock.patch("core.services.lobby.uuid.uuid4", return_value="123"),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
),
|
||||
@@ -221,11 +209,6 @@ def test_request_entry_public_room(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "123"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
@@ -235,9 +218,10 @@ def test_request_entry_public_room(settings):
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
# Verify lobby cache is still empty after the request
|
||||
# The accepted participant is persisted, out of the waiting list
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
assert len(lobby_keys) == 1
|
||||
assert cache.get(lobby_keys[0])["status"] == "accepted"
|
||||
|
||||
|
||||
def test_request_entry_authenticated_user_public_room(settings):
|
||||
@@ -247,7 +231,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
@@ -256,9 +239,8 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService,
|
||||
"_get_or_create_participant_id",
|
||||
mock.patch(
|
||||
"core.services.lobby.uuid.uuid4",
|
||||
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
),
|
||||
mock.patch.object(
|
||||
@@ -273,11 +255,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
@@ -287,9 +264,10 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
# Verify lobby cache is still empty after the request
|
||||
# The accepted participant is persisted, out of the waiting list
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
assert len(lobby_keys) == 1
|
||||
assert cache.get(lobby_keys[0])["status"] == "accepted"
|
||||
|
||||
|
||||
def test_request_entry_waiting_participant_public_room(settings):
|
||||
@@ -297,7 +275,6 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add a waiting participant to the room's lobby cache
|
||||
@@ -311,9 +288,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
},
|
||||
)
|
||||
|
||||
# Simulate a browser with existing participant cookie
|
||||
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
|
||||
|
||||
# Simulate a returning participant echoing its identifier
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
@@ -322,16 +297,14 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "user1"},
|
||||
{
|
||||
"username": "user1",
|
||||
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
@@ -637,15 +610,14 @@ def test_list_waiting_participants_empty(settings):
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_throttling_anonymous_without_cookie(
|
||||
def test_request_entry_throttling_anonymous_unidentified(
|
||||
mock_notify_participants, mock_generate_livekit_config, settings
|
||||
):
|
||||
"""Anonymous users without a cookie should not be throttled."""
|
||||
"""Requests without a participant identifier should not be throttled."""
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
||||
|
||||
response = client.post(
|
||||
@@ -654,9 +626,6 @@ def test_request_entry_throttling_anonymous_without_cookie(
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.cookies.get("mocked-cookie") is not None
|
||||
|
||||
client.cookies.clear() # Simulate a new cookieless request
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
@@ -670,34 +639,32 @@ def test_request_entry_throttling_anonymous_without_cookie(
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_throttling_anonymous_with_cookie(
|
||||
def test_request_entry_throttling_anonymous_identified(
|
||||
mock_notify_participants, mock_generate_livekit_config, settings
|
||||
):
|
||||
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
|
||||
"""Identified requests should be throttled after exceeding the rate limit."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
participant_id = str(uuid.uuid4())
|
||||
client.cookies.load({"mocked-cookie": participant_id})
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
@@ -716,7 +683,6 @@ def test_request_entry_throttling_authenticated_user(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
response = client.post(
|
||||
@@ -737,3 +703,124 @@ def test_request_entry_throttling_authenticated_user(
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
def test_request_entry_with_participant_id(settings):
|
||||
"""Echoing the previously issued identifier preserves the lobby identity across requests."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
participant_id = response.json()["id"]
|
||||
|
||||
# Echoing the identifier must be recognized as the same
|
||||
# participant: no duplicate in the lobby
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == participant_id
|
||||
assert response.json()["status"] == "waiting"
|
||||
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
|
||||
def test_request_entry_unknown_participant_id_not_seeded(settings):
|
||||
"""An identifier unknown to the room's lobby must not be honored."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
forged_id = str(uuid.uuid4())
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": forged_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] != forged_id
|
||||
|
||||
# Nothing was stored under the forged identifier
|
||||
assert cache.get(f"mocked-cache-prefix_{room.id}_{forged_id}") is None
|
||||
|
||||
|
||||
def test_request_entry_participant_id_bound_to_room(settings):
|
||||
"""An identifier minted for one room must not be honored in another."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
other_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
participant_id = response.json()["id"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{other_room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": participant_id},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] != participant_id
|
||||
|
||||
|
||||
def test_request_entry_legacy_cookie_ignored():
|
||||
"""The retired cookie channel must not be honored anymore."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
legacy_participant_id = str(uuid.uuid4())
|
||||
client.cookies["lobbyParticipantId"] = legacy_participant_id
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
returned_id = response.json()["id"]
|
||||
assert returned_id != legacy_participant_id
|
||||
uuid.UUID(returned_id)
|
||||
|
||||
|
||||
def test_request_entry_malformed_participant_id(settings):
|
||||
"""A non-UUID identifier is rejected by the serializer with a 400."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user", "participant_id": "../../../evil-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "participant_id" in response.json()
|
||||
|
||||
@@ -20,7 +20,11 @@ from rest_framework.test import APIClient
|
||||
|
||||
from core import utils
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.lobby import (
|
||||
LobbyParticipant,
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -87,7 +91,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -113,7 +117,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -153,7 +157,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -300,7 +304,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -330,7 +334,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -361,7 +365,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -381,7 +385,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -412,7 +416,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -440,7 +444,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -469,7 +473,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -849,7 +853,15 @@ def test_remove_participant_success_lobby_cache(mock_livekit_client):
|
||||
participant_identity = str(uuid4())
|
||||
|
||||
# Create participant in lobby cache first
|
||||
LobbyService().enter(room.id, participant_identity, "John doe")
|
||||
LobbyService()._save_participant(
|
||||
room.id,
|
||||
LobbyParticipant(
|
||||
id=participant_identity,
|
||||
username="John doe",
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
color="#123456",
|
||||
),
|
||||
)
|
||||
|
||||
# Accept participant
|
||||
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
||||
@@ -1020,3 +1032,6 @@ def test_remove_participant_not_found(mock_livekit_client):
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
# todo - try to pass another scheme to make sure it defers to the next auth
|
||||
|
||||
@@ -69,7 +69,10 @@ def test_toggle_hand_raise_success(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -84,7 +87,10 @@ def test_toggle_hand_lower_success(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"raised": False},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -101,7 +107,10 @@ def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -117,7 +126,10 @@ def test_toggle_hand_identity_derived_from_token(
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -128,7 +140,9 @@ def test_toggle_hand_missing_raised_field(room, token):
|
||||
"""Test toggle hand with missing raised field returns 400."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "raised" in response.data
|
||||
@@ -142,7 +156,7 @@ def test_toggle_hand_invalid_raised_field(room, token):
|
||||
url,
|
||||
{"raised": "not-a-boolean"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -166,7 +180,10 @@ def test_toggle_hand_forbidden_token_for_wrong_room(user):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -181,7 +198,10 @@ def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
@@ -200,7 +220,7 @@ def test_toggle_hand_raise_success_anonymous(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -220,7 +240,7 @@ def test_toggle_hand_lower_success_anonymous(
|
||||
url,
|
||||
{"raised": False},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -240,7 +260,7 @@ def test_toggle_hand_identity_derived_from_token_anonymous(
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -257,7 +277,10 @@ def test_rename_participant_success(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -272,7 +295,10 @@ def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"name": "Jane Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -286,7 +312,10 @@ def test_rename_participant_uses_identity_from_token(
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -298,7 +327,7 @@ def test_rename_participant_empty_name(room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -309,7 +338,9 @@ def test_rename_participant_missing_name(room, token):
|
||||
"""Test rename with missing name field returns 400."""
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
url, {}, format="json", HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "name" in response.data
|
||||
@@ -320,7 +351,10 @@ def test_rename_participant_name_too_long(room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"name": "a" * 256},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
@@ -348,7 +382,7 @@ def test_rename_participant_forbidden_token_for_wrong_room(user):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {wrong_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -363,7 +397,10 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
@@ -382,7 +419,7 @@ def test_rename_participant_success_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -402,7 +439,7 @@ def test_rename_participant_uses_identity_from_token_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -419,7 +456,7 @@ def test_rename_participant_sets_correct_name_anonymous(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||
@@ -436,7 +473,7 @@ def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_t
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -462,7 +499,7 @@ def test_toggle_hand_expired_token(room, expired_token):
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -476,7 +513,7 @@ def test_rename_participant_expired_token(room, expired_token):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {expired_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -490,7 +527,7 @@ def test_toggle_hand_malformed_token(room):
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
||||
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -504,7 +541,10 @@ def test_toggle_hand_room_not_found(user):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -519,7 +559,10 @@ def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"raised": True},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -536,7 +579,7 @@ def test_rename_participant_malformed_token(room):
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
||||
HTTP_AUTHORIZATION="X-LiveKit-Token this-is-not-a-valid-jwt",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
@@ -550,7 +593,10 @@ def test_rename_participant_room_not_found(user):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
@@ -565,10 +611,16 @@ def test_rename_participant_not_found(mock_livekit_client, room, token):
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
url,
|
||||
{"name": "John Doe"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
# todo - try to pass another scheme to make sure it defers to the next auth
|
||||
|
||||
@@ -3,11 +3,14 @@ Test rooms API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -507,3 +510,40 @@ def test_api_rooms_retrieve_administrators(
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_authenticated_with_user_access_token():
|
||||
"""A user access token should retrieve a room exactly like a session would."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == str(room.id)
|
||||
# Authenticated as the owner: privileged fields are included
|
||||
assert response.data["pin_code"] == room.pin_code
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_start_subtitle_invalid_token():
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION="Bearer invalid-token",
|
||||
HTTP_AUTHORIZATION="X-LiveKit-Token invalid-token",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -128,7 +128,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -148,7 +148,7 @@ def test_start_subtitle_valid_token(
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -178,7 +178,7 @@ def test_start_subtitle_twirp_error(
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
@@ -198,7 +198,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -219,10 +219,13 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||
HTTP_AUTHORIZATION=f"X-LiveKit-Token {mock_livekit_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Invalid LiveKit token: Signature verification failed"
|
||||
}
|
||||
|
||||
|
||||
# todo - try to pass another scheme to make sure it defers to the next auth
|
||||
|
||||
@@ -3,8 +3,12 @@ Test rooms API endpoints in the Meet core app: update.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -437,3 +441,45 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def generate_user_access_token(user):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_update_authenticated_with_user_access_token():
|
||||
"""Role-based permissions apply unchanged with a user access token."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "member")])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
# A simple member cannot update the room
|
||||
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||
assert response.status_code == 403
|
||||
|
||||
# An administrator can
|
||||
room.accesses.filter(user=user).update(role="administrator")
|
||||
response = client.patch(f"/api/v1.0/rooms/{room.id!s}/", {"name": "new name"})
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "new name"
|
||||
|
||||
@@ -3,7 +3,6 @@ Test lobby service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613, W0212, R0913
|
||||
# ruff: noqa: PLR0913, PLR0917
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
@@ -11,7 +10,6 @@ from unittest import mock
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -135,59 +133,6 @@ def test_get_cache_key(lobby_service, participant_id):
|
||||
assert cache_key == expected_key
|
||||
|
||||
|
||||
def test_get_or_create_participant_id_from_cookie(lobby_service):
|
||||
"""Test extracting participant ID from cookie."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
|
||||
|
||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
||||
|
||||
assert participant_id == "existing-id"
|
||||
|
||||
|
||||
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
|
||||
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
|
||||
"""Test creating new participant ID when cookie is missing."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {}
|
||||
|
||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
||||
|
||||
assert participant_id == "generated-id"
|
||||
mock_uuid4.assert_called_once()
|
||||
|
||||
|
||||
def test_prepare_response_existing_cookie(lobby_service, participant_id):
|
||||
"""Test response preparation with existing cookie."""
|
||||
response = HttpResponse()
|
||||
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
|
||||
|
||||
lobby_service.prepare_response(response, participant_id)
|
||||
|
||||
# Verify cookie wasn't set again
|
||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
||||
assert cookie.value == "existing-cookie"
|
||||
assert cookie.value != participant_id
|
||||
|
||||
|
||||
def test_prepare_response_new_cookie(lobby_service, participant_id):
|
||||
"""Test response preparation with new cookie."""
|
||||
response = HttpResponse()
|
||||
|
||||
lobby_service.prepare_response(response, participant_id)
|
||||
|
||||
# Verify cookie was set
|
||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
||||
assert cookie is not None
|
||||
assert cookie.value == participant_id
|
||||
assert cookie["httponly"] is True
|
||||
assert cookie["secure"] is True
|
||||
assert cookie["samesite"] == "Lax"
|
||||
|
||||
# It's a session cookies (no max_age specified):
|
||||
assert not cookie["max-age"]
|
||||
|
||||
|
||||
def test_can_bypass_lobby_public_room(lobby_service):
|
||||
"""Should return True for public rooms regardless of user auth and role."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -266,11 +211,12 @@ def test_request_entry_public_room(
|
||||
color="#123456",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, request, username, participant_id=participant_id
|
||||
)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
@@ -304,11 +250,12 @@ def test_request_entry_trusted_room(
|
||||
color="#123456",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, request, username, participant_id=participant_id
|
||||
)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
@@ -325,18 +272,19 @@ def test_request_entry_trusted_room(
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService.enter")
|
||||
@mock.patch("core.services.lobby.LobbyService._notify_entry_request")
|
||||
@mock.patch("core.services.lobby.LobbyService._create_participant")
|
||||
def test_request_entry_new_participant(
|
||||
mock_enter, lobby_service, participant_id, username
|
||||
mock_create, mock_notify, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for a new participant."""
|
||||
"""A new participant gets a server-minted identifier - any provided
|
||||
one is unknown to the lobby and therefore discarded - and the room is
|
||||
notified of the entry request."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=None)
|
||||
|
||||
participant_data = LobbyParticipant(
|
||||
@@ -345,14 +293,20 @@ def test_request_entry_new_participant(
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
mock_enter.return_value = participant_data
|
||||
mock_create.return_value = participant_data
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
forged_id = str(uuid.uuid4())
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, request, username, participant_id=forged_id
|
||||
)
|
||||
|
||||
assert participant == participant_data
|
||||
assert livekit_config is None
|
||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
# The provided identifier was looked up, found unknown, and replaced
|
||||
# by a freshly minted participant
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, forged_id)
|
||||
mock_create.assert_called_once_with(room.id, username)
|
||||
mock_notify.assert_called_once_with(str(room.id))
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
|
||||
@@ -361,7 +315,6 @@ def test_request_entry_waiting_participant(
|
||||
):
|
||||
"""Test requesting entry for a waiting participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -372,10 +325,11 @@ def test_request_entry_waiting_participant(
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, request, username, participant_id=participant_id
|
||||
)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert livekit_config is None
|
||||
@@ -390,7 +344,6 @@ def test_request_entry_accepted_participant(
|
||||
"""Test requesting entry for an accepted participant."""
|
||||
request = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -400,12 +353,13 @@ def test_request_entry_accepted_participant(
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, request, username, participant_id=participant_id
|
||||
)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
@@ -428,7 +382,6 @@ def test_request_entry_participant_with_role(
|
||||
"""Test requesting entry for a participant with a role on the room."""
|
||||
request = mock.Mock()
|
||||
request.user = UserFactory()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -440,12 +393,13 @@ def test_request_entry_participant_with_role(
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
participant, livekit_config = lobby_service.request_entry(
|
||||
room, request, username, participant_id=participant_id
|
||||
)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
@@ -472,73 +426,47 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_enter_success(
|
||||
mock_notify,
|
||||
def test_create_participant(
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
settings,
|
||||
):
|
||||
"""Test successful participant entry."""
|
||||
"""A created participant is waiting, colored, and persisted."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
participant = lobby_service.enter(room.id, participant_id, username)
|
||||
participant = lobby_service._create_participant(room.id, username)
|
||||
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
# The identifier is minted server-side
|
||||
uuid.UUID(participant.id)
|
||||
mock_generate_color.assert_called_once_with(participant.id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
assert participant.id == participant_id
|
||||
assert participant.color == "#123456"
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant.id)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_enter_with_notification_error(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
):
|
||||
"""Test participant entry with notification error."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
def test_notify_entry_request_with_notification_error(mock_notify, lobby_service):
|
||||
"""A notification error must not break the entry request flow."""
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
participant = lobby_service.enter(room.id, participant_id, username)
|
||||
lobby_service._notify_entry_request("room-id")
|
||||
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name="room-id", notification_data={"type": "participantWaiting"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Unit tests for the TransitCodeService.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_create_code_returns_unique_opaque_codes():
|
||||
"""Each created code should be a distinct high-entropy string."""
|
||||
user = UserFactory()
|
||||
service = TransitCodeService()
|
||||
|
||||
codes = {service.create_code(user) for _ in range(5)}
|
||||
|
||||
assert len(codes) == 5
|
||||
for code in codes:
|
||||
assert len(code) >= 43
|
||||
|
||||
|
||||
def test_consume_code_returns_stored_data_once():
|
||||
"""Consuming a code should return its data exactly once."""
|
||||
user = UserFactory()
|
||||
service = TransitCodeService()
|
||||
|
||||
code = service.create_code(user, client_id="my-app")
|
||||
|
||||
assert service.consume_code(code) == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": "my-app",
|
||||
}
|
||||
# Single use: a second consumption fails
|
||||
assert service.consume_code(code) is None
|
||||
|
||||
|
||||
def test_consume_code_unknown_or_empty():
|
||||
"""Unknown or empty codes should not be consumable."""
|
||||
service = TransitCodeService()
|
||||
|
||||
assert service.consume_code("unknown-code") is None
|
||||
assert service.consume_code("") is None
|
||||
assert service.consume_code(None) is None
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Tests for user access JWT authentication on the core API.
|
||||
|
||||
The token authenticates the user on the whole API, exactly like a session
|
||||
cookie would (similar to lib-jitsi-meet's token authentication): the
|
||||
existing role-based permissions apply unchanged. Room endpoint coverage
|
||||
with a user access token lives in the room test files.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
from core.models import RoleChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def generate_user_access_token(user, **overrides):
|
||||
"""Generate a valid user access JWT signed with the token secret."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=django_settings.USER_ACCESS_TOKEN_TTL),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
"scope": "user:access",
|
||||
}
|
||||
payload.update(overrides)
|
||||
payload = {key: value for key, value in payload.items() if value is not None}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_user_access_token_users_me():
|
||||
"""A user access token should authenticate the user on /users/me/."""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["email"] == user.email
|
||||
|
||||
|
||||
def test_user_access_token_expired():
|
||||
"""An expired user access token should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = generate_user_access_token(
|
||||
user,
|
||||
iat=now - timedelta(hours=3),
|
||||
exp=now - timedelta(hours=1),
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "token expired" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_invalid_signature():
|
||||
"""A token signed with the wrong key should defer and end unauthenticated."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": django_settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
"aud": django_settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=600),
|
||||
"user_id": str(user.id),
|
||||
"token_type": "user_access",
|
||||
"client_id": "test-app",
|
||||
},
|
||||
"wrong-secret-key-padded-for-minimum-len!",
|
||||
algorithm=django_settings.USER_ACCESS_TOKEN_ALG,
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
# UserAccessJWTAuthentication defers, session auth finds no session
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_wrong_token_type():
|
||||
"""A verified token with the wrong 'token_type' claim should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_user_access_token(user, token_type="addons")
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token type" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_missing_client_id_claim():
|
||||
"""A token without the issuance-audit claim should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_user_access_token(user, client_id=None)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "invalid token claims" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_user_access_token_inactive_user():
|
||||
"""A user access token for an inactive user should be rejected."""
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_feature_disabled(settings):
|
||||
"""When the feature is disabled, user access tokens should be ignored."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {generate_user_access_token(user)}")
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_user_access_token_does_not_break_session_authentication():
|
||||
"""A session-authenticated user should keep full access to the API."""
|
||||
user = UserFactory()
|
||||
RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
|
||||
|
||||
def test_user_access_token_application_jwt_not_accepted_on_core_api():
|
||||
"""An application-delegation JWT must not authenticate on the core API."""
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=600),
|
||||
"user_id": str(user.id),
|
||||
"client_id": "some-client",
|
||||
"delegated": True,
|
||||
"scope": "rooms:retrieve",
|
||||
},
|
||||
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
|
||||
# The user token backend must defer (wrong signature) and the request
|
||||
# must end up unauthenticated.
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Test users API endpoints in the Meet core app: exchange transit code.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
import secrets
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import UserFactory
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def decode_user_access_token(token, settings):
|
||||
"""Decode a user access token with the token secret."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.USER_ACCESS_TOKEN_SECRET_KEY,
|
||||
algorithms=[settings.USER_ACCESS_TOKEN_ALG],
|
||||
issuer=settings.USER_ACCESS_TOKEN_ISSUER,
|
||||
audience=settings.USER_ACCESS_TOKEN_AUDIENCE,
|
||||
)
|
||||
|
||||
|
||||
def generate_unknown_code(settings):
|
||||
"""Generate a well-formed code that was never stored."""
|
||||
return secrets.token_urlsafe(settings.TRANSIT_CODE_NBYTES)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""Return an anonymous API client with a random source IP.
|
||||
|
||||
A fresh IP per test isolates the anonymous throttle history, both
|
||||
between the tests of this module and between test runs.
|
||||
"""
|
||||
# `secrets` rather than `random`: the global random module is seeded
|
||||
# deterministically by the factories, its sequence repeats across runs.
|
||||
remote_addr = (
|
||||
f"10.{secrets.randbelow(256)}.{secrets.randbelow(256)}"
|
||||
f".{secrets.randbelow(254) + 1}"
|
||||
)
|
||||
return APIClient(REMOTE_ADDR=remote_addr)
|
||||
|
||||
|
||||
def test_exchange_access_token_missing_code(client):
|
||||
"""The exchange endpoint should validate its input."""
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "code" in response.data
|
||||
|
||||
|
||||
def test_exchange_access_token_malformed_code(client):
|
||||
"""A code whose length cannot match a generated one should be a 400."""
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": "not-a-valid-code"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "invalid transit code format" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_unknown_code(client, settings):
|
||||
"""A well-formed but unknown code should be denied."""
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "invalid, expired or already used" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_success(client, settings):
|
||||
"""A valid transit code should be exchangeable for an access token."""
|
||||
user = UserFactory()
|
||||
|
||||
code = TransitCodeService().create_code(user, client_id="my-app")
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["token_type"] == settings.USER_ACCESS_TOKEN_TYPE
|
||||
assert response.data["expires_in"] == settings.USER_ACCESS_TOKEN_TTL
|
||||
assert response.data["scope"] == "user:access"
|
||||
|
||||
payload = decode_user_access_token(response.data["access_token"], settings)
|
||||
assert payload["token_type"] == "user_access"
|
||||
assert payload["user_id"] == str(user.id)
|
||||
assert payload["client_id"] == "my-app"
|
||||
assert payload["exp"] - payload["iat"] == settings.USER_ACCESS_TOKEN_TTL
|
||||
|
||||
|
||||
def test_exchange_access_token_single_use(client):
|
||||
"""A transit code should be exchangeable exactly once."""
|
||||
user = UserFactory()
|
||||
|
||||
code = TransitCodeService().create_code(user)
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
assert response.status_code == 200
|
||||
|
||||
# Replaying the same code must be denied
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
assert response.status_code == 403
|
||||
assert "invalid, expired or already used" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_inactive_user(client):
|
||||
"""A code minted for a now-inactive user should be denied."""
|
||||
user = UserFactory()
|
||||
|
||||
code = TransitCodeService().create_code(user)
|
||||
|
||||
user.is_active = False
|
||||
user.save()
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "no longer access" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_exchange_access_token_feature_disabled(client, settings):
|
||||
"""The exchange endpoint should return 404 when the feature is disabled."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
code = TransitCodeService().create_code(user)
|
||||
|
||||
response = client.post("/api/v1.0/users/exchange-access-token/", {"code": code})
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_exchange_access_token_throttled(client, settings):
|
||||
"""Anonymous exchange attempts should be rate limited."""
|
||||
throttle_rates = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]
|
||||
initial_rate = throttle_rates["exchange_access_token"]
|
||||
# The rates dict is mutated in place: restore it explicitly, the
|
||||
# `settings` fixture only rolls back attribute assignments.
|
||||
throttle_rates["exchange_access_token"] = "2/minute"
|
||||
|
||||
try:
|
||||
for _ in range(2):
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/users/exchange-access-token/",
|
||||
{"code": generate_unknown_code(settings)},
|
||||
)
|
||||
assert response.status_code == 429
|
||||
finally:
|
||||
throttle_rates["exchange_access_token"] = initial_rate
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Tests for external API /users endpoints (transit codes)
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import ApplicationFactory, UserFactory
|
||||
from core.models import ApplicationScope
|
||||
from core.services.transit_code import TransitCodeService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def generate_test_token(user, scopes):
|
||||
"""Generate a valid application JWT token for testing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
scope_string = " ".join(scopes)
|
||||
|
||||
application = ApplicationFactory()
|
||||
|
||||
payload = {
|
||||
"iss": django_settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": django_settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now
|
||||
+ timedelta(seconds=django_settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||
"client_id": str(application.client_id),
|
||||
"scope": scope_string,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
django_settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=django_settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_users_transit_code_requires_authentication():
|
||||
"""Minting a transit code without authentication should return 401."""
|
||||
client = APIClient()
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_users_transit_code_missing_scope():
|
||||
"""A token without the 'users:session' scope should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "users:session" in str(response.data)
|
||||
|
||||
|
||||
def test_api_users_transit_code_success(settings):
|
||||
"""A delegated user with the scope should be able to mint a transit code."""
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["expires_in"] == settings.TRANSIT_CODE_TTL
|
||||
|
||||
code = response.data["transit_code"]
|
||||
# Opaque, high-entropy random string
|
||||
assert len(code) == (4 * settings.TRANSIT_CODE_NBYTES + 2) // 3
|
||||
|
||||
# The code is stored server-side and references the delegated user
|
||||
code_data = TransitCodeService().consume_code(code)
|
||||
assert code_data == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": mock.ANY,
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_transit_code_with_rs_token():
|
||||
"""A resource-server-authenticated user should be able to mint a code."""
|
||||
user = UserFactory()
|
||||
|
||||
# todo - add a decorator instead
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication,
|
||||
"authenticate",
|
||||
return_value=(user, {"scope": "users:session", "client_id": "rs-client"}),
|
||||
) as mock_rs_authenticate:
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
mock_rs_authenticate.assert_called_once()
|
||||
assert response.status_code == 200
|
||||
|
||||
code_data = TransitCodeService().consume_code(response.data["transit_code"])
|
||||
assert code_data == {
|
||||
"user_id": str(user.id),
|
||||
"client_id": "rs-client",
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_transit_code_with_rs_token_missing_scope():
|
||||
"""A resource server token without the scope should be rejected."""
|
||||
user = UserFactory()
|
||||
|
||||
# todo - add a decorator instead
|
||||
with mock.patch.object(
|
||||
ResourceServerAuthentication,
|
||||
"authenticate",
|
||||
return_value=(user, {"scope": "rooms:list", "client_id": "rs-client"}),
|
||||
):
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some-opaque-rs-token")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "users:session" in str(response.data)
|
||||
|
||||
|
||||
def test_api_users_transit_code_feature_disabled(settings):
|
||||
"""Minting a transit code should return 404 when the feature is disabled."""
|
||||
settings.USER_ACCESS_TOKEN_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_users_transit_code_inactive_user():
|
||||
"""An inactive user should not be able to mint a transit code."""
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.USERS_SESSION])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/users/transit-code/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# todo - add a test to make sure the addon authentification doesn't allow to mint a transit token
|
||||
@@ -48,6 +48,11 @@ external_router.register(
|
||||
external_viewsets.RoomViewSet,
|
||||
basename="external_room",
|
||||
)
|
||||
external_router.register(
|
||||
"users",
|
||||
external_viewsets.UserViewSet,
|
||||
basename="external_user",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
|
||||
@@ -324,6 +324,7 @@ class Base(Configuration):
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"core.authentication.user_token.UserAccessJWTAuthentication",
|
||||
"core.authentication.backends.SessionAuthenticationWith401",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
@@ -344,6 +345,11 @@ class Base(Configuration):
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"exchange_access_token": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="EXCHANGE_ACCESS_TOKEN_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"creation_callback": values.Value(
|
||||
default="600/minute",
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
@@ -875,11 +881,6 @@ class Base(Configuration):
|
||||
environ_name="LOBBY_NOTIFICATION_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
LOBBY_COOKIE_NAME = values.Value(
|
||||
"lobbyParticipantId",
|
||||
environ_name="LOBBY_COOKIE_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Calendar integrations
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
@@ -1002,6 +1003,61 @@ class Base(Configuration):
|
||||
environ_name="APPLICATION_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# User access tokens (embedded frontend / iframe support)
|
||||
USER_ACCESS_TOKEN_ENABLED = values.BooleanValue(
|
||||
False, environ_name="USER_ACCESS_TOKEN_ENABLED", environ_prefix=None
|
||||
)
|
||||
USER_ACCESS_TOKEN_SECRET_KEY = SecretFileValue(
|
||||
None, environ_name="USER_ACCESS_TOKEN_SECRET_KEY", environ_prefix=None
|
||||
)
|
||||
USER_ACCESS_TOKEN_ALG = values.Value(
|
||||
"HS256",
|
||||
environ_name="USER_ACCESS_TOKEN_ALG",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_ISSUER = values.Value(
|
||||
"lasuite-meet",
|
||||
environ_name="USER_ACCESS_TOKEN_ISSUER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_AUDIENCE = values.Value(
|
||||
None,
|
||||
environ_name="USER_ACCESS_TOKEN_AUDIENCE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Lifetime of the user access token obtained through the exchange
|
||||
# endpoint. It never transits through a URL, so it can cover a full
|
||||
# meeting (default: 2 hours).
|
||||
USER_ACCESS_TOKEN_TTL = values.PositiveIntegerValue(
|
||||
7200,
|
||||
environ_name="USER_ACCESS_TOKEN_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Lifetime of the single-use transit code handed to the frontend
|
||||
# through a URL fragment. Kept very short by design: it must only
|
||||
# survive the redirect and the exchange call.
|
||||
TRANSIT_CODE_TTL = values.PositiveIntegerValue(
|
||||
60,
|
||||
environ_name="TRANSIT_CODE_TTL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
TRANSIT_CODE_CACHE_PREFIX = values.Value(
|
||||
"transit-code",
|
||||
environ_name="TRANSIT_CODE_CACHE_PREFIX",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Number of random bytes per code (48 bytes -> 64 url-safe characters)
|
||||
TRANSIT_CODE_NBYTES = values.PositiveIntegerValue(
|
||||
48,
|
||||
environ_name="TRANSIT_CODE_NBYTES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
USER_ACCESS_TOKEN_TYPE = values.Value(
|
||||
"Bearer",
|
||||
environ_name="USER_ACCESS_TOKEN_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Warning: EXTERNAL_API_ALLOW_PUBLIC_ACCESS is ignored when
|
||||
# EXTERNAL_API_DEFAULT_ACCESS_LEVEL=public.
|
||||
EXTERNAL_API_ALLOW_PUBLIC_ACCESS = values.BooleanValue(
|
||||
@@ -1298,9 +1354,13 @@ class Test(Base):
|
||||
ADDONS_ENABLED = True
|
||||
ADDONS_CSRF_SECRET = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
ADDONS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-addons" # noqa:S105
|
||||
USER_ACCESS_TOKEN_ENABLED = True
|
||||
USER_ACCESS_TOKEN_SECRET_KEY = "secret-key-padded-for-minimum-len!-room" # noqa:S105
|
||||
USER_ACCESS_TOKEN_AUDIENCE = "Test inc." # noqa:S105
|
||||
|
||||
CONNECTION_TEST_ENABLED = True
|
||||
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.25.1"
|
||||
version = "1.27.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.25.1"
|
||||
version = "1.27.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -4,6 +4,11 @@ server {
|
||||
server_tokens off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location ^~ /assets/mediapipe/wasm/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# Serve static files with caching
|
||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
|
||||
Generated
+2
-12
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.25.1",
|
||||
"version": "1.27.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.25.1",
|
||||
"version": "1.27.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
@@ -22,7 +22,6 @@
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.3.1",
|
||||
@@ -4710,15 +4709,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/derive-valtio": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
|
||||
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"valtio": ">=2.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.25.1",
|
||||
"version": "1.27.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -29,7 +29,6 @@
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.3.1",
|
||||
|
||||
+24
-17
@@ -12,6 +12,7 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||
|
||||
@@ -24,23 +25,29 @@ function App() {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
<TransitCodeGate>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
component={route.Component}
|
||||
/>
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</TransitCodeGate>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { ApiError } from './ApiError'
|
||||
import { apiUrl } from './apiUrl'
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
|
||||
export const fetchApi = async <T = Record<string, unknown>>(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> => {
|
||||
const csrfToken = getCsrfToken()
|
||||
// Embedded (iframe) mode: the user access token obtained through the
|
||||
// transit code exchange authenticates requests in place of the session
|
||||
// cookie, which is blocked in third-party contexts.
|
||||
const accessToken = getAccessToken()
|
||||
const response = await fetch(apiUrl(url), {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||
...(!!accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,24 +2,31 @@ import { Button } from '@/primitives'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||
|
||||
export const SoundTester = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
|
||||
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||
const { devices, activeDeviceId } = useMediaDeviceSelect({
|
||||
kind: 'audiooutput',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const updateActiveId = async (deviceId: string) => {
|
||||
try {
|
||||
await audioRef?.current?.setSinkId(deviceId)
|
||||
} catch (error) {
|
||||
console.error(`Error setting sinkId: ${error}`)
|
||||
if (!canTestAudioOutput() || !activeDeviceId) return
|
||||
if (!devices.some((device) => device.deviceId === activeDeviceId)) return
|
||||
audioRef.current?.setSinkId(activeDeviceId).catch((error) => {
|
||||
if (error instanceof DOMException && error.name === 'NotFoundError') {
|
||||
return
|
||||
}
|
||||
}
|
||||
updateActiveId(activeDeviceId)
|
||||
}, [activeDeviceId])
|
||||
reportError(
|
||||
'device_switch_failure',
|
||||
new Error(`Error setting sinkId: ${error}`)
|
||||
)
|
||||
})
|
||||
}, [devices, activeDeviceId])
|
||||
|
||||
// prevent pausing the sound
|
||||
navigator.mediaSession.setActionHandler('pause', function () {})
|
||||
@@ -28,9 +35,13 @@ export const SoundTester = () => {
|
||||
<>
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
onPress={() => {
|
||||
audioRef?.current?.play()
|
||||
setIsPlaying(true)
|
||||
onPress={async () => {
|
||||
try {
|
||||
await audioRef?.current?.play()
|
||||
setIsPlaying(true)
|
||||
} catch {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
isDisabled={isPlaying}
|
||||
@@ -44,7 +55,7 @@ export const SoundTester = () => {
|
||||
{/* eslint-disable jsx-a11y/media-has-caption */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="sounds/uprise.mp3"
|
||||
src="/sounds/uprise.mp3"
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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 { useLocation } from 'wouter'
|
||||
import { type PostHog } from 'posthog-js'
|
||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
|
||||
let posthog: PostHog | null = null
|
||||
|
||||
const getPosthog = async () => {
|
||||
if (!posthog) posthog = (await import('posthog-js')).default
|
||||
return posthog
|
||||
}
|
||||
import { getPosthog } from '../utils'
|
||||
import { filterExceptions } from '../exceptionFilters'
|
||||
|
||||
export const startAnalyticsSession = (data: ApiUser) => {
|
||||
getPosthog().then((ph) => {
|
||||
@@ -38,7 +31,6 @@ export const useAnalytics = ({
|
||||
flags_api_host,
|
||||
isDisabled,
|
||||
}: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,6 +41,14 @@ export const useAnalytics = ({
|
||||
api_host: host,
|
||||
flags_api_host: flags_api_host,
|
||||
person_profiles: 'always',
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageleave: true,
|
||||
capture_exceptions: {
|
||||
capture_unhandled_errors: true,
|
||||
capture_unhandled_rejections: true,
|
||||
capture_console_errors: true,
|
||||
},
|
||||
before_send: filterExceptions,
|
||||
})
|
||||
})
|
||||
}, [id, host, flags_api_host, isDisabled])
|
||||
@@ -58,12 +58,5 @@ export const useAnalytics = ({
|
||||
startAnalyticsSession(user)
|
||||
}, [user])
|
||||
|
||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||
useEffect(() => {
|
||||
getPosthog().then((ph) => {
|
||||
ph.capture('$pageview')
|
||||
})
|
||||
}, [location])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { getPosthog } from './utils'
|
||||
|
||||
export const captureEvent = (
|
||||
event: string,
|
||||
props?: Record<string, unknown>
|
||||
) => {
|
||||
void getPosthog()
|
||||
.then((ph) => {
|
||||
ph.capture(event, props)
|
||||
})
|
||||
.catch(() => {
|
||||
/* telemetry must never break the app */
|
||||
})
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[telemetry] ${event}`, props)
|
||||
}
|
||||
}
|
||||
|
||||
export type LogCode =
|
||||
// media
|
||||
| 'join_preview_failure'
|
||||
| 'room_media_failure'
|
||||
| 'livekit_room_error'
|
||||
| 'device_switch_failure'
|
||||
| 'permission_poll_failure'
|
||||
| 'media_devices_error_event'
|
||||
// non-media families
|
||||
| 'participant_mute_api_failure'
|
||||
| 'permissions_api_failure'
|
||||
| 'effects_processor_failure'
|
||||
| 'clipboard_failure'
|
||||
| 'fullscreen_failure'
|
||||
| 'publish_sources_failure'
|
||||
| 'disconnect_failure'
|
||||
| 'generic_failure'
|
||||
|
||||
export const reportError = (
|
||||
logCode: LogCode,
|
||||
error: unknown,
|
||||
extraInfo: Record<string, unknown> = {}
|
||||
): void => {
|
||||
const e = error instanceof Error ? error : new Error(String(error))
|
||||
void getPosthog()
|
||||
.then((ph) => {
|
||||
ph.captureException(e, {
|
||||
log_code: logCode,
|
||||
error_name: e.name,
|
||||
error_message: e.message,
|
||||
...extraInfo,
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[${logCode}]`, e, extraInfo)
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceSnapshot {
|
||||
cam_count: number
|
||||
mic_count: number
|
||||
out_count: number
|
||||
labels_visible: boolean
|
||||
saved_cam_present: boolean | null
|
||||
saved_mic_present: boolean | null
|
||||
saved_video_device_id_set: boolean
|
||||
saved_audio_device_id_set: boolean
|
||||
audio_enabled: boolean | null
|
||||
video_enabled: boolean | null
|
||||
cam_permission: PermissionState | 'unknown'
|
||||
mic_permission: PermissionState | 'unknown'
|
||||
}
|
||||
|
||||
/** Reads the persisted LiveKit user choices without importing the store. */
|
||||
const readPersistedChoices = (): {
|
||||
videoDeviceId?: string
|
||||
audioDeviceId?: string
|
||||
videoEnabled?: boolean
|
||||
audioEnabled?: boolean
|
||||
} => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('lk-user-choices') ?? '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const queryPermission = async (
|
||||
name: 'camera' | 'microphone'
|
||||
): Promise<PermissionState | 'unknown'> => {
|
||||
try {
|
||||
const status = await navigator.permissions.query({
|
||||
name: name as PermissionName,
|
||||
})
|
||||
return status.state
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceSnapshot = async (): Promise<DeviceSnapshot> => {
|
||||
const choices = readPersistedChoices()
|
||||
let devices: MediaDeviceInfo[] = []
|
||||
try {
|
||||
devices = await navigator.mediaDevices.enumerateDevices()
|
||||
} catch {
|
||||
/* snapshot stays partial */
|
||||
}
|
||||
const ofKind = (k: MediaDeviceKind) => devices.filter((d) => d.kind === k)
|
||||
const present = (k: MediaDeviceKind, id?: string) =>
|
||||
id ? ofKind(k).some((d) => d.deviceId === id) : null
|
||||
|
||||
const [cam_permission, mic_permission] = await Promise.all([
|
||||
queryPermission('camera'),
|
||||
queryPermission('microphone'),
|
||||
])
|
||||
|
||||
return {
|
||||
cam_count: ofKind('videoinput').length,
|
||||
mic_count: ofKind('audioinput').length,
|
||||
out_count: ofKind('audiooutput').length,
|
||||
labels_visible: devices.some((d) => !!d.label),
|
||||
saved_cam_present: present('videoinput', choices.videoDeviceId),
|
||||
saved_mic_present: present('audioinput', choices.audioDeviceId),
|
||||
saved_video_device_id_set: !!choices.videoDeviceId,
|
||||
saved_audio_device_id_set: !!choices.audioDeviceId,
|
||||
audio_enabled: choices.audioEnabled ?? null,
|
||||
video_enabled: choices.videoEnabled ?? null,
|
||||
cam_permission,
|
||||
mic_permission,
|
||||
}
|
||||
}
|
||||
|
||||
export const captureMediaEvent = async (
|
||||
event:
|
||||
| 'media-device-error'
|
||||
| 'media-acquisition'
|
||||
| 'media-device-topology'
|
||||
| 'media-device-success'
|
||||
| 'device-not-found'
|
||||
| 'permissions-denied'
|
||||
| 'screen-share-permission-denied'
|
||||
| 'silent-mic-detected'
|
||||
| 'silent-mic-analyser-unavailable'
|
||||
| 'silent-mic-recovered'
|
||||
| 'visit-room'
|
||||
| 'connection-event',
|
||||
props: Record<string, unknown>
|
||||
) => {
|
||||
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
|
||||
let posthog: PostHog | null = null
|
||||
|
||||
export const getPosthog = async () => {
|
||||
if (!posthog) posthog = (await import('posthog-js')).default
|
||||
return posthog
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { setAccessToken } from '@/stores/accessToken'
|
||||
import { consumeTransitCodeFromFragment } from '../utils/transitCode'
|
||||
|
||||
type ApiAccessToken = {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
scope: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a single-use transit code for a user access token.
|
||||
*
|
||||
* The endpoint is unauthenticated: the code itself is the credential.
|
||||
*/
|
||||
export const exchangeAccessToken = (code: string): Promise<ApiAccessToken> => {
|
||||
return fetchApi<ApiAccessToken>('/users/exchange-access-token/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
}
|
||||
|
||||
const runInitialization = async (): Promise<void> => {
|
||||
const code = consumeTransitCodeFromFragment()
|
||||
|
||||
if (!code) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { access_token } = await exchangeAccessToken(code)
|
||||
setAccessToken(access_token)
|
||||
} catch (error) {
|
||||
console.warn('Transit code exchange failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
let initialization: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Bootstrap the embedded (iframe) authentication, if applicable.
|
||||
*
|
||||
* When, and only when, a transit code is present in the URL fragment,
|
||||
* exchange it for a user access token and keep it in the in-memory
|
||||
* accessToken store: fetchApi then sends it as a Bearer header on every
|
||||
* api call, authenticating the user exactly like a session cookie would.
|
||||
*
|
||||
* Must complete before anything fires an authenticated query, which the
|
||||
* TransitCodeGate component guarantees by gating the app tree on it.
|
||||
*
|
||||
* Memoized: the fragment is consumed and the code exchanged exactly once,
|
||||
* however many times this is called (StrictMode double-invoked effects,
|
||||
* among others). Subsequent calls await the same promise.
|
||||
*
|
||||
* A failed exchange (expired or already used code) is not fatal: the app
|
||||
* starts unauthenticated, falling back to the regular session flow.
|
||||
*/
|
||||
export const initializeAccessTokenFromFragment = (): Promise<void> => {
|
||||
if (!initialization) {
|
||||
initialization = runInitialization()
|
||||
}
|
||||
return initialization
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ApiError } from '@/api/ApiError'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
|
||||
/**
|
||||
* fetch the logged-in user from the api.
|
||||
@@ -25,7 +26,13 @@ export const fetchUser = (
|
||||
if (error instanceof ApiError && error.statusCode === 401) {
|
||||
// make sure to not resolve the promise while trying to silent login
|
||||
// so that consumers of fetchUser don't think the work already ended
|
||||
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
||||
// Never attempt a silent login in embedded (token) mode: an OIDC
|
||||
// redirect inside the iframe would break the embed.
|
||||
if (
|
||||
opts.attemptSilent &&
|
||||
!getAccessToken() &&
|
||||
canAttemptSilentLogin()
|
||||
) {
|
||||
attemptSilentLogin(30)
|
||||
} else {
|
||||
resolve(false)
|
||||
|
||||
@@ -6,6 +6,8 @@ import { queryClient } from '@/api/queryClient'
|
||||
import { updateUserPreferences } from './updateUserPreferences'
|
||||
import { convertToBackendLanguage } from '@/utils/languages'
|
||||
import { useUser } from './useUser'
|
||||
import { ApiError } from '@/api/ApiError.ts'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
/**
|
||||
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
import { useHash } from '@/hooks/useHash'
|
||||
import { initializeAccessTokenFromFragment } from '../api/exchangeAccessToken'
|
||||
import { hasTransitCodeInFragment } from '../utils/transitCode'
|
||||
|
||||
/**
|
||||
* Gates the app tree on the embedded (iframe) authentication bootstrap.
|
||||
*
|
||||
* Without a transit code in the URL fragment — the overwhelmingly common
|
||||
* case — the component early returns children synchronously: no state,
|
||||
* no effect, no extra render, no loading screen.
|
||||
*
|
||||
* When a transit code is present, children are not mounted until it has
|
||||
* been exchanged for a user access token, so that every authenticated
|
||||
* query already carries the Authorization header. A loading screen is
|
||||
* displayed in the meantime, as UserAware does.
|
||||
*/
|
||||
export const TransitCodeGate = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const hash = useHash()
|
||||
|
||||
// Latch the decision on the initial hash: the bootstrap scrubs the
|
||||
// fragment as soon as it starts, and the gate must not flip back to the
|
||||
// fast path while the exchange is still in flight.
|
||||
const [needsExchange] = useState(() => hasTransitCodeInFragment(hash))
|
||||
|
||||
if (!needsExchange) {
|
||||
return children
|
||||
}
|
||||
|
||||
return <TransitCodeExchange>{children}</TransitCodeExchange>
|
||||
}
|
||||
|
||||
/**
|
||||
* Only ever mounted when a transit code is present: runs the memoized
|
||||
* bootstrap (safe against StrictMode double-invoked effects) and holds
|
||||
* children back until it settles.
|
||||
*/
|
||||
const TransitCodeExchange = ({ children }: { children: React.ReactNode }) => {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
initializeAccessTokenFromFragment().finally(() => {
|
||||
console.log('$$ transit code exchange finished')
|
||||
if (isMounted) {
|
||||
console.log('$$ setIsReady')
|
||||
setIsReady(true)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
console.log('$$ isReady', isReady)
|
||||
|
||||
return isReady ? (
|
||||
children
|
||||
) : (
|
||||
<LoadingScreen header={false} footer={false} delay={1000} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const TRANSIT_CODE_FRAGMENT_PARAM = 'transit_code'
|
||||
|
||||
/**
|
||||
* Whether a URL fragment carries a transit code. Pure check, does not
|
||||
* consume anything.
|
||||
*/
|
||||
export const hasTransitCodeInFragment = (hash: string): boolean => {
|
||||
if (!hash) {
|
||||
return false
|
||||
}
|
||||
return new URLSearchParams(hash.replace(/^#/, '')).has(
|
||||
TRANSIT_CODE_FRAGMENT_PARAM
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the transit code from the URL fragment, if any.
|
||||
*
|
||||
* The fragment is scrubbed from the address bar immediately, before any
|
||||
* network call, so the code never lingers in the browser history. Any
|
||||
* other fragment content is preserved.
|
||||
*/
|
||||
export const consumeTransitCodeFromFragment = (): string | null => {
|
||||
if (typeof window === 'undefined' || !window.location.hash) {
|
||||
return null
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.hash.substring(1))
|
||||
const code = params.get(TRANSIT_CODE_FRAGMENT_PARAM)
|
||||
|
||||
if (!code) {
|
||||
return null
|
||||
}
|
||||
|
||||
params.delete(TRANSIT_CODE_FRAGMENT_PARAM)
|
||||
const remaining = params.toString()
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
window.location.pathname +
|
||||
window.location.search +
|
||||
(remaining ? `#${remaining}` : '')
|
||||
)
|
||||
|
||||
return code
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChatRow } from '@/stores/chat'
|
||||
import React, { useMemo } from 'react'
|
||||
import { formatChatMessageLinks } from '@livekit/components-react'
|
||||
import { formatChatMessageLinks } from '../utils'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { accessTokenStore } from '@/stores/accessToken'
|
||||
import { resolveMediaUrl } from '../utils/resolveMediaUrl'
|
||||
|
||||
/**
|
||||
* Reactive companion of resolveMediaUrl for browser-native consumers
|
||||
* (CSS url(), img src attributes): resolves a list of /media/ URLs and
|
||||
* returns a stable lookup, identity in regular mode.
|
||||
*
|
||||
* Object URLs come from the shared session-lifetime cache and are never
|
||||
* revoked here: they may be used concurrently by the background
|
||||
* processors.
|
||||
*/
|
||||
export const useResolvedMediaUrls = (
|
||||
urls: (string | null | undefined)[]
|
||||
): ((url: string) => string) => {
|
||||
const [resolved, setResolved] = useState<Record<string, string>>({})
|
||||
const { accessToken } = useSnapshot(accessTokenStore)
|
||||
|
||||
// Stable dependency for the effect, insensitive to array identity
|
||||
const urlsKey = urls.filter(Boolean).sort().join('\n')
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !urlsKey) {
|
||||
return
|
||||
}
|
||||
|
||||
let isMounted = true
|
||||
|
||||
const resolveAll = async () => {
|
||||
const entries = await Promise.all(
|
||||
urlsKey.split('\n').map(async (url) => {
|
||||
try {
|
||||
return [url, await resolveMediaUrl(url)] as const
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
return [url, url] as const
|
||||
}
|
||||
})
|
||||
)
|
||||
if (isMounted) {
|
||||
setResolved(Object.fromEntries(entries))
|
||||
}
|
||||
}
|
||||
resolveAll()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [accessToken, urlsKey])
|
||||
|
||||
// Stable identity so that consumers can safely list the resolver in
|
||||
// their memo dependencies: it only changes when resolutions land.
|
||||
return useCallback((url: string) => resolved[url] ?? url, [resolved])
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
|
||||
// Session-lifetime cache: object URLs are shared between every consumer
|
||||
// of a given media (background processors, thumbnails) and are therefore
|
||||
// never revoked - their number is bounded by the user's custom
|
||||
// backgrounds, and they die with the page like the access token does.
|
||||
const objectUrlCache = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Resolve an authenticated /media/ URL for the embedded (token) mode.
|
||||
*
|
||||
* Media files are served behind an nginx auth_request subrequest that
|
||||
* authenticates the original request. In regular mode the session cookie
|
||||
* rides along browser-native loads (img.src, CSS url()) and the URL is
|
||||
* returned unchanged, without any fetch. In embedded mode the
|
||||
* third-party cookie is blocked and native loads cannot carry the
|
||||
* Authorization header, so the media is fetched here with the Bearer
|
||||
* header - which the media-auth endpoint accepts, as it sits behind the
|
||||
* default authentication stack - and exposed as a blob object URL.
|
||||
*/
|
||||
export const resolveMediaUrl = async (url: string): Promise<string> => {
|
||||
const accessToken = getAccessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
return url
|
||||
}
|
||||
|
||||
const cached = objectUrlCache.get(url)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to resolve media url ${url}: HTTP ${response.status}`
|
||||
)
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(await response.blob())
|
||||
objectUrlCache.set(url, objectUrl)
|
||||
|
||||
return objectUrl
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { css } from '@/styled-system/css'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -160,7 +161,11 @@ const Home = () => {
|
||||
window.location.replace(data.external_home_url)
|
||||
} catch (error) {
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { NotificationType } from '@/features/notifications/NotificationType
|
||||
// fixme - handle dynamic audio output changes
|
||||
export const useNotificationSound = () => {
|
||||
const notificationsSnap = useSnapshot(notificationsStore)
|
||||
const [play] = useSound('./sounds/notifications.mp3', {
|
||||
const [play] = useSound('/sounds/notifications.mp3', {
|
||||
sprite: {
|
||||
participantJoined: [0, 1150],
|
||||
handRaised: [1400, 180],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NotificationDuration } from './NotificationDuration'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import type { NotificationPayload } from './NotificationPayload'
|
||||
import type { RecordingMode } from '@/features/recording'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const notifyAutoMutedOnJoin = () => {
|
||||
toastQueue.add(
|
||||
@@ -55,7 +56,9 @@ export const decodeNotificationDataReceived = (
|
||||
return parsed as NotificationPayload
|
||||
} catch (error) {
|
||||
// Handle errors appropriately for your application
|
||||
console.error('Failed to decode notification payload:', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'Failed to decode notification payload:',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useLowerHandParticipant } from './lowerHandParticipant'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useLowerHandParticipants = () => {
|
||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||
@@ -11,7 +12,9 @@ export const useLowerHandParticipants = () => {
|
||||
)
|
||||
return Promise.all(promises)
|
||||
} catch (error) {
|
||||
console.error('An error occurred while lowering hands :', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'An error occurred while lowering hands :',
|
||||
})
|
||||
throw new Error('An error occurred while lowering hands.', {
|
||||
cause: error,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useParticipantRole = () => {
|
||||
const data = useRoomData()
|
||||
@@ -22,8 +23,11 @@ export const useParticipantRole = () => {
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
reportError(
|
||||
'generic_failure',
|
||||
new Error(
|
||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '../../participants/api/listWaitingParticipants'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
|
||||
@@ -87,7 +88,7 @@ export const useWaitingParticipants = () => {
|
||||
|
||||
await refetchWaiting()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
reportError('generic_failure', e)
|
||||
setListEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useEffect, useMemo } from 'react'
|
||||
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
|
||||
|
||||
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
|
||||
const pipStoreSnap = useSnapshot(documentPictureInPictureStore, {
|
||||
sync: true,
|
||||
})
|
||||
|
||||
const container = useMemo(() => {
|
||||
return pipStoreSnap?.window?.document.getElementById('root')
|
||||
@@ -19,7 +21,7 @@ const InternalPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!container) return null
|
||||
if (!container || !container.isConnected) return null
|
||||
|
||||
return createPortal(
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ref, useSnapshot } from 'valtio'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const IS_PIP_SUPPORTED =
|
||||
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
|
||||
@@ -59,21 +61,29 @@ export const usePictureInPicture = () => {
|
||||
if (!IS_PIP_SUPPORTED) return null
|
||||
if (isOpen) return null
|
||||
|
||||
let pipWindow: Window
|
||||
try {
|
||||
const pipWindow =
|
||||
pipWindow =
|
||||
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).documentPictureInPicture.requestWindow({
|
||||
width,
|
||||
height,
|
||||
})
|
||||
} catch {
|
||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
initializeTitleAndLanguage(pipWindow, t('title'))
|
||||
initializePortalContainer(pipWindow)
|
||||
syncStyles(pipWindow)
|
||||
|
||||
const cleanUp = () => {
|
||||
if (documentPictureInPictureStore.window === pipWindow) {
|
||||
documentPictureInPictureStore.window = null
|
||||
flushSync(() => {
|
||||
documentPictureInPictureStore.window = null
|
||||
})
|
||||
}
|
||||
}
|
||||
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
|
||||
@@ -82,8 +92,10 @@ export const usePictureInPicture = () => {
|
||||
})
|
||||
documentPictureInPictureStore.window = ref(pipWindow)
|
||||
} catch (error) {
|
||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||
console.error('Failed to open Picture-in-Picture window', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'pip_init_failure',
|
||||
})
|
||||
pipWindow.close()
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,8 +26,8 @@ const StyledContainer = styled('div', {
|
||||
backgroundColor: 'primaryDark.100',
|
||||
maxWidth: '100%',
|
||||
opacity: 0,
|
||||
transform: 'translateY(3.25rem)',
|
||||
transition: 'opacity, transform',
|
||||
translate: '0 3.25rem',
|
||||
transition: 'opacity, translate',
|
||||
transitionDuration: '0.5s',
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pointerEvents: 'none',
|
||||
@@ -36,7 +36,7 @@ const StyledContainer = styled('div', {
|
||||
isVisible: {
|
||||
true: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
translate: '0 0',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
},
|
||||
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
|
||||
shouldBeCenteredWithToggleButton,
|
||||
setShouldBeCenteredWithToggleButton,
|
||||
] = useState(false)
|
||||
const [rightOffset, setRightOffset] = useState(0)
|
||||
const [offsetX, setOffsetX] = useState(0)
|
||||
|
||||
const updateArrows = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
@@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldBeCenteredWithToggleButton || isMobile) {
|
||||
setRightOffset(0)
|
||||
setOffsetX(0)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({
|
||||
const containerCenterX = containerRect.left + containerRect.width / 2
|
||||
const shift = toggleCenterX - containerCenterX
|
||||
if (Math.abs(shift) < 0.5) return
|
||||
setRightOffset((prev) => prev - shift * 2)
|
||||
setOffsetX((prev) => prev + shift)
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
|
||||
isVisible={isVisible}
|
||||
style={
|
||||
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
||||
? { marginRight: `${rightOffset}px` }
|
||||
? { transform: `translateX(${offsetX}px)` }
|
||||
: { margin: '0 15px' }
|
||||
}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
notifyRecordingSaveInProgress,
|
||||
useNotifyParticipants,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { NoAccessView } from './NoAccessView'
|
||||
import { ControlsButton } from './ControlsButton'
|
||||
@@ -29,6 +28,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { LimitDescription } from './LimitDescription'
|
||||
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const ScreenRecordingSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
@@ -63,7 +63,7 @@ export const ScreenRecordingSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingRequested,
|
||||
})
|
||||
posthog.capture('screen-recording-requested', {})
|
||||
captureEvent('screen-recording-requested', {})
|
||||
}
|
||||
|
||||
const handleScreenRecording = async () => {
|
||||
@@ -100,13 +100,15 @@ export const ScreenRecordingSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStarted,
|
||||
})
|
||||
posthog.capture('screen-recording-started', {
|
||||
captureEvent('screen-recording-started', {
|
||||
includeTranscript: includeTranscript,
|
||||
language: selectedLanguageKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle recording:', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'Failed to handle recording:',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
useNotifyParticipants,
|
||||
notifyRecordingSaveInProgress,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||
@@ -35,6 +34,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { LimitDescription } from './LimitDescription'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const TranscriptSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
@@ -76,7 +76,7 @@ export const TranscriptSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionRequested,
|
||||
})
|
||||
posthog.capture('transcript-requested', {})
|
||||
captureEvent('transcript-requested', {})
|
||||
}
|
||||
|
||||
const handleTranscript = async () => {
|
||||
@@ -121,13 +121,15 @@ export const TranscriptSidePanel = () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionStarted,
|
||||
})
|
||||
posthog.capture('transcript-started', {
|
||||
captureEvent('transcript-started', {
|
||||
includeScreenRecording: includeScreenRecording,
|
||||
language: selectedLanguageKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'Failed to handle transcript:',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRoomInfo } from '@livekit/components-react'
|
||||
import { useMemo } from 'react'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useRoomMetadata = () => {
|
||||
const { metadata } = useRoomInfo()
|
||||
@@ -8,7 +9,9 @@ export const useRoomMetadata = () => {
|
||||
try {
|
||||
return JSON.parse(metadata)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse room metadata:', error)
|
||||
reportError('generic_failure', error, {
|
||||
context: 'Failed to parse room metadata:',
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -9,6 +9,8 @@ import { fetchApi } from '@/api/fetchApi'
|
||||
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const apiRoomData = useRoomData()
|
||||
@@ -31,12 +33,15 @@ export const useMuteParticipant = () => {
|
||||
|
||||
// Guard against undefined token for non-admin users
|
||||
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
|
||||
console.error('Cannot mute participant: missing auth token')
|
||||
reportError(
|
||||
'participant_mute_api_failure',
|
||||
new Error('Cannot mute participant: missing auth token')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const headers = !isAdminOrOwner
|
||||
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
|
||||
? getLiveKitAuthHeaders(apiRoomData.livekit.token)
|
||||
: undefined
|
||||
|
||||
let response
|
||||
@@ -53,8 +58,11 @@ export const useMuteParticipant = () => {
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
reportError(
|
||||
'participant_mute_api_failure',
|
||||
new Error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -65,8 +73,11 @@ export const useMuteParticipant = () => {
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||
reportError(
|
||||
'participant_mute_api_failure',
|
||||
new Error(
|
||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useMuteParticipant } from './muteParticipant'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useMuteParticipants = () => {
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
@@ -11,7 +12,9 @@ export const useMuteParticipants = () => {
|
||||
)
|
||||
return Promise.all(promises)
|
||||
} catch (error) {
|
||||
console.error('An error occurred while muting participants :', error)
|
||||
reportError('participant_mute_api_failure', error, {
|
||||
context: 'An error occurred while muting participants :',
|
||||
})
|
||||
throw new Error('An error occurred while muting participants.', {
|
||||
cause: error,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||
|
||||
export const useRenameParticipant = () => {
|
||||
const data = useRoomData()
|
||||
@@ -15,11 +16,10 @@ export const useRenameParticipant = () => {
|
||||
throw new Error('LiveKit token is not available')
|
||||
}
|
||||
|
||||
const headers = getLiveKitAuthHeaders(token)
|
||||
return fetchApi(`rooms/${data.id}/rename/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import type { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
||||
import { getLobbyParticipantId } from '@/stores/lobby'
|
||||
|
||||
export interface RequestEntryParams {
|
||||
roomId: string
|
||||
@@ -15,6 +16,7 @@ export enum ApiLobbyStatus {
|
||||
}
|
||||
|
||||
export interface ApiRequestEntry {
|
||||
id?: string
|
||||
status: ApiLobbyStatus
|
||||
livekit?: ApiLiveKit
|
||||
}
|
||||
@@ -23,10 +25,12 @@ export const requestEntry = async ({
|
||||
roomId,
|
||||
username = '',
|
||||
}: RequestEntryParams) => {
|
||||
const participantId = getLobbyParticipantId(roomId)
|
||||
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
...(participantId && { participant_id: participantId }),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Participant, Track } from 'livekit-client'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
type Source = Track.Source
|
||||
|
||||
export const useParticipantPermissions = () => {
|
||||
@@ -32,8 +33,11 @@ export const useParticipantPermissions = () => {
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
reportError(
|
||||
'permissions_api_failure',
|
||||
new Error(
|
||||
`Failed to update participant's permissions ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Participant, Track } from 'livekit-client'
|
||||
import { useParticipantPermissions } from './updateParticipantPermissions'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
type Source = Track.Source
|
||||
|
||||
export const useUpdateParticipantsPermissions = () => {
|
||||
@@ -15,7 +16,9 @@ export const useUpdateParticipantsPermissions = () => {
|
||||
)
|
||||
return Promise.all(promises)
|
||||
} catch (error) {
|
||||
console.error('An error occurred while updating permissions :', error)
|
||||
reportError('permissions_api_failure', error, {
|
||||
context: 'An error occurred while updating permissions :',
|
||||
})
|
||||
throw new Error('An error occurred while updating permissions.', {
|
||||
cause: error,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { getLiveKitAuthHeaders } from '../utils/getLiveKitAuthHeaders'
|
||||
|
||||
export const useRaiseHand = () => {
|
||||
const data = useRoomData()
|
||||
@@ -15,11 +16,10 @@ export const useRaiseHand = () => {
|
||||
throw new Error('LiveKit token is not available')
|
||||
}
|
||||
|
||||
const headers = getLiveKitAuthHeaders(token)
|
||||
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
raised,
|
||||
}),
|
||||
|
||||
@@ -25,18 +25,17 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
|
||||
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
import { userStore } from '@/stores/user'
|
||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -47,7 +46,6 @@ export const Conference = ({
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
}) => {
|
||||
const posthog = usePostHog()
|
||||
const { data: apiConfig } = useConfig()
|
||||
|
||||
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||
@@ -57,8 +55,8 @@ export const Conference = ({
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId, posthog])
|
||||
void captureMediaEvent('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
const fetchKey = [keys.room, roomId]
|
||||
|
||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||
@@ -170,14 +168,6 @@ export const Conference = ({
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
error: MediaDeviceFailure | null
|
||||
kind: MediaDeviceKind | null
|
||||
}>({
|
||||
error: null,
|
||||
kind: null,
|
||||
})
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const hasAutoMutedRef = useRef(false)
|
||||
@@ -235,7 +225,11 @@ export const Conference = ({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
onError={(e) => {
|
||||
posthog.captureException(e)
|
||||
const failure = MediaDeviceFailure.getFailure(e)
|
||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||
reportError('livekit_room_error', e, {
|
||||
path: 'connect_publish',
|
||||
})
|
||||
}}
|
||||
onConnected={async () => {
|
||||
if (!apiConfig) return
|
||||
@@ -253,23 +247,8 @@ export const Conference = ({
|
||||
onDisconnected={(e) => {
|
||||
const metadata = {
|
||||
room_id: roomId,
|
||||
pc_publisher: connectionObserverStore.publisher && {
|
||||
...connectionObserverStore.publisher,
|
||||
},
|
||||
pc_subscriber: connectionObserverStore.subscriber && {
|
||||
...connectionObserverStore.subscriber,
|
||||
},
|
||||
pc_publisher_changes_count:
|
||||
connectionObserverStore.publisherChangesCount,
|
||||
pc_subscriber_changes_count:
|
||||
connectionObserverStore.subscriberChangesCount,
|
||||
}
|
||||
|
||||
connectionObserverStore.publisher = null
|
||||
connectionObserverStore.publisherChangesCount = 0
|
||||
connectionObserverStore.subscriber = null
|
||||
connectionObserverStore.subscriberChangesCount = 0
|
||||
|
||||
switch (e) {
|
||||
case DisconnectReason.CLIENT_INITIATED:
|
||||
navigateTo(
|
||||
@@ -295,18 +274,10 @@ export const Conference = ({
|
||||
return
|
||||
}
|
||||
}}
|
||||
onMediaDeviceFailure={(e, kind) => {
|
||||
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
|
||||
setMediaDeviceError({ error: e, kind })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<WatchMediaDeviceErrors />
|
||||
<VideoConference />
|
||||
{!isMobile && <InviteDialog mode={mode} />}
|
||||
<MediaDeviceErrorAlert
|
||||
{...mediaDeviceError}
|
||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||
/>
|
||||
<PictureInPictureConference />
|
||||
</LiveKitRoom>
|
||||
</Screen>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||
|
||||
const roomData = useRoomData()
|
||||
const roomUrl = getRouteUrl('room', roomData?.slug)
|
||||
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
|
||||
|
||||
const telephony = useTelephony()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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 { css } from '@/styled-system/css'
|
||||
import { Dialog, H } from '@/primitives'
|
||||
import { Button, Dialog, H, P } from '@/primitives'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
|
||||
import {
|
||||
closePermissionsDialog,
|
||||
closeSystemPermissionsDialog,
|
||||
permissionsStore,
|
||||
} from '@/stores/permissions'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { injectIconIntoTranslation } from '@/utils/translation'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
import { type OS, getOS } from '@/utils/os'
|
||||
|
||||
type StepsOs = 'macos' | 'windows' | 'android' | 'other'
|
||||
|
||||
const STEPS_OS: Record<OS, StepsOs> = {
|
||||
macos: 'macos',
|
||||
windows: 'windows',
|
||||
android: 'android',
|
||||
linux: 'other',
|
||||
other: 'other',
|
||||
}
|
||||
|
||||
const getSystemSettingsUrl = (os: OS, label: string): string | null => {
|
||||
if (os === 'macos') {
|
||||
if (label === 'camera')
|
||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'
|
||||
if (label === 'microphone')
|
||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
|
||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy'
|
||||
}
|
||||
if (os === 'windows') {
|
||||
if (label === 'camera') return 'ms-settings:privacy-webcam'
|
||||
if (label === 'microphone') return 'ms-settings:privacy-microphone'
|
||||
return 'ms-settings:privacy'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const SystemPermissions = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'systemPermissionDialog' })
|
||||
const permissions = useSnapshot(permissionsStore)
|
||||
const os = useMemo(() => getOS() || 'other', [])
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (permissions.microphoneSystemDenied && permissions.cameraSystemDenied) {
|
||||
return 'cameraAndMicrophone'
|
||||
}
|
||||
if (permissions.cameraSystemDenied) return 'camera'
|
||||
return 'microphone'
|
||||
}, [permissions])
|
||||
|
||||
const isOpen = permissions.isSystemPermissionDialogOpen
|
||||
|
||||
// Auto-close once access works again (the user fixed the OS settings).
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
!permissions.microphoneSystemDenied &&
|
||||
!permissions.cameraSystemDenied
|
||||
) {
|
||||
closeSystemPermissionsDialog()
|
||||
}
|
||||
}, [isOpen, permissions])
|
||||
|
||||
const device = t(`device.${label}`)
|
||||
const settingsUrl = getSystemSettingsUrl(os, label)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t(`heading.${label}`)}
|
||||
onClose={closeSystemPermissionsDialog}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '500px',
|
||||
})}
|
||||
>
|
||||
<H lvl={1}>{t(`heading.${label}`)}</H>
|
||||
<P>{t('intro', { device })}</P>
|
||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<li key={index}>
|
||||
{t(`steps.${STEPS_OS[os] || 'other'}.${index + 1}`, { device })}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{settingsUrl && (
|
||||
<div className={css({ marginTop: '2rem' })}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
window.open(settingsUrl, '_blank')
|
||||
}}
|
||||
>
|
||||
{t('openSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton component - ensures permissions sync runs only once across the app.
|
||||
@@ -65,68 +166,74 @@ export const Permissions = () => {
|
||||
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={permissions.isPermissionDialogOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
})}
|
||||
onClose={closePermissionsDialog}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
md: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
<>
|
||||
<SystemPermissions />
|
||||
<Dialog
|
||||
isOpen={permissions.isPermissionDialogOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
})}
|
||||
onClose={closePermissionsDialog}
|
||||
>
|
||||
<img
|
||||
src="/assets/camera_mic_permission.svg"
|
||||
alt=""
|
||||
className={css({
|
||||
width: '100%',
|
||||
minHeight: '290px',
|
||||
maxWidth: '290px',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '400px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
md: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<H lvl={2}>
|
||||
{t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
<img
|
||||
src="/assets/camera_mic_permission.svg"
|
||||
alt=""
|
||||
className={css({
|
||||
width: '100%',
|
||||
minHeight: '290px',
|
||||
maxWidth: '290px',
|
||||
})}
|
||||
</H>
|
||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||
<li>
|
||||
{isSafari() ? (
|
||||
t('body.openMenu.safari', {
|
||||
appDomain: window.origin.replace('https://', ''),
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{descriptionBeforeIcon}
|
||||
<span
|
||||
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
||||
>
|
||||
<RiEqualizer2Line />
|
||||
</span>
|
||||
{descriptionAfterIcon}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
<li>{t(`body.details.${permissionLabel}`)}</li>
|
||||
</ol>
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '400px',
|
||||
})}
|
||||
>
|
||||
<H lvl={2}>
|
||||
{t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
})}
|
||||
</H>
|
||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||
<li>
|
||||
{isSafari() ? (
|
||||
t('body.openMenu.safari', {
|
||||
appDomain: window.origin.replace('https://', ''),
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{descriptionBeforeIcon}
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line />
|
||||
</span>
|
||||
{descriptionAfterIcon}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
<li>{t(`body.details.${permissionLabel}`)}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</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 { cva } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { CandidateInfo } from '@/stores/connectionObserver'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
const Card = styled('div', {
|
||||
base: {
|
||||
@@ -72,11 +70,9 @@ const labelRecipe = cva({
|
||||
})
|
||||
|
||||
const OpenFeedback = ({
|
||||
posthog,
|
||||
onNext,
|
||||
metadata,
|
||||
}: {
|
||||
posthog: PostHog
|
||||
onNext: () => void
|
||||
metadata?: Record<string, unknown>
|
||||
}) => {
|
||||
@@ -90,7 +86,7 @@ const OpenFeedback = ({
|
||||
|
||||
const onSubmit = () => {
|
||||
try {
|
||||
posthog.capture('open-feedback', {
|
||||
captureEvent('open-feedback', {
|
||||
feedback,
|
||||
...metadata,
|
||||
})
|
||||
@@ -141,12 +137,10 @@ const OpenFeedback = ({
|
||||
}
|
||||
|
||||
const RateQuality = ({
|
||||
posthog,
|
||||
onNext,
|
||||
metadata,
|
||||
maxRating = 5,
|
||||
}: {
|
||||
posthog: PostHog
|
||||
onNext: () => void
|
||||
metadata?: Record<string, unknown>
|
||||
maxRating?: number
|
||||
@@ -160,7 +154,7 @@ const RateQuality = ({
|
||||
|
||||
const onSubmit = () => {
|
||||
try {
|
||||
posthog.capture('quality-rating', {
|
||||
captureEvent('quality-rating', {
|
||||
rating: selectedRating,
|
||||
...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 = {
|
||||
room_id?: string
|
||||
pc_publisher?: CandidateInfo
|
||||
pc_subscriber?: CandidateInfo
|
||||
pc_publisher_changes_count?: number
|
||||
pc_subscriber_changes_count?: number
|
||||
}
|
||||
|
||||
export const Rating = ({
|
||||
@@ -318,12 +247,6 @@ export const Rating = ({
|
||||
metadata: RatingMetadata
|
||||
}) => {
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const posthog = usePostHog()
|
||||
|
||||
const isUserAnonymous = useMemo(() => {
|
||||
return posthog.get_property('$user_state') == 'anonymous'
|
||||
}, [posthog])
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
const sessionId = useMemo(() => crypto.randomUUID(), [])
|
||||
@@ -339,37 +262,14 @@ export const Rating = ({
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
if (step == 0) {
|
||||
return (
|
||||
<RateQuality
|
||||
posthog={posthog}
|
||||
onNext={() => setStep(step + 1)}
|
||||
metadata={metadata}
|
||||
/>
|
||||
)
|
||||
return <RateQuality onNext={() => setStep(step + 1)} metadata={metadata} />
|
||||
}
|
||||
|
||||
if (step == 1) {
|
||||
return (
|
||||
<OpenFeedback
|
||||
posthog={posthog}
|
||||
onNext={() => setStep(step + 1)}
|
||||
metadata={metadata}
|
||||
/>
|
||||
)
|
||||
return <OpenFeedback onNext={() => setStep(step + 1)} metadata={metadata} />
|
||||
}
|
||||
|
||||
if (step == 2) {
|
||||
return isUserAnonymous ? (
|
||||
<AuthenticationMessage
|
||||
posthog={posthog}
|
||||
onNext={() => setStep(step + 1)}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmationMessage onNext={() => setStep(0)} />
|
||||
)
|
||||
}
|
||||
|
||||
if (step == 3) {
|
||||
return <ConfirmationMessage onNext={() => setStep(0)} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ApiLobbyStatus,
|
||||
type ApiRequestEntry,
|
||||
} from '../api/requestEntry'
|
||||
import { setLobbyParticipantId } from '@/stores/lobby'
|
||||
|
||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
@@ -43,6 +44,11 @@ export const useLobby = ({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
|
||||
if (response.id) {
|
||||
setLobbyParticipantId(roomId, response.id)
|
||||
}
|
||||
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -1,160 +1,36 @@
|
||||
import { useEffect } from 'react'
|
||||
import { permissionsStore } from '@/stores/permissions'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
|
||||
const POLLING_TIME = 500
|
||||
import { syncPermissions } from '@/stores/permissions'
|
||||
|
||||
export const useWatchPermissions = () => {
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | undefined
|
||||
let intervalId: ReturnType<typeof setTimeout> | undefined
|
||||
let isCancelled = false
|
||||
const sync = () => void syncPermissions()
|
||||
sync()
|
||||
|
||||
const checkPermissions = async () => {
|
||||
try {
|
||||
if (!navigator.permissions) {
|
||||
if (!isCancelled) {
|
||||
permissionsStore.cameraPermission = 'unavailable'
|
||||
permissionsStore.microphonePermission = 'unavailable'
|
||||
}
|
||||
return
|
||||
}
|
||||
navigator.mediaDevices?.addEventListener?.('devicechange', sync)
|
||||
window.addEventListener('focus', sync)
|
||||
|
||||
const [cameraPermission, microphonePermission] = await Promise.all([
|
||||
navigator.permissions.query({ name: 'camera' }),
|
||||
navigator.permissions.query({ name: 'microphone' }),
|
||||
])
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
/**
|
||||
* Safari Permission API Limitation Workaround
|
||||
*
|
||||
* Safari has a known issue where permission change events are not reliably fired
|
||||
* when users interact with permission prompts. This is documented in Apple's forums:
|
||||
* https://developer.apple.com/forums/thread/757353
|
||||
*
|
||||
* The problem:
|
||||
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
|
||||
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
|
||||
* - This leaves the UI in an inconsistent state showing outdated permission status
|
||||
*
|
||||
* The solution:
|
||||
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
|
||||
* - Continue polling until both permissions are no longer in 'prompt' state
|
||||
* - This ensures we catch permission changes even when Safari fails to fire events
|
||||
*
|
||||
* This polling is Safari-specific and only activates when needed to minimize performance impact.
|
||||
*/
|
||||
if (
|
||||
isSafari() &&
|
||||
(cameraPermission.state === 'prompt' ||
|
||||
microphonePermission.state === 'prompt')
|
||||
) {
|
||||
// Start polling every 1 second if either permission is in 'prompt' state
|
||||
if (!intervalId) {
|
||||
intervalId = setInterval(async () => {
|
||||
try {
|
||||
const [updatedCamera, updatedMicrophone] = await Promise.all([
|
||||
navigator.permissions.query({ name: 'camera' }),
|
||||
navigator.permissions.query({ name: 'microphone' }),
|
||||
])
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
const cameraChanged =
|
||||
permissionsStore.cameraPermission !== updatedCamera.state
|
||||
const microphoneChanged =
|
||||
permissionsStore.microphonePermission !==
|
||||
updatedMicrophone.state
|
||||
|
||||
if (cameraChanged) {
|
||||
permissionsStore.cameraPermission = updatedCamera.state
|
||||
}
|
||||
|
||||
if (microphoneChanged) {
|
||||
permissionsStore.microphonePermission =
|
||||
updatedMicrophone.state
|
||||
}
|
||||
|
||||
if (
|
||||
updatedCamera.state !== 'prompt' &&
|
||||
updatedMicrophone.state !== 'prompt'
|
||||
) {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = undefined
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error('Error polling permissions:', error)
|
||||
}
|
||||
}
|
||||
}, POLLING_TIME)
|
||||
}
|
||||
}
|
||||
|
||||
permissionsStore.cameraPermission = cameraPermission.state
|
||||
permissionsStore.microphonePermission = microphonePermission.state
|
||||
|
||||
const handleCameraChange = (e: Event) => {
|
||||
const target = e.target as PermissionStatus
|
||||
permissionsStore.cameraPermission = target.state
|
||||
|
||||
if (
|
||||
intervalId &&
|
||||
target.state !== 'prompt' &&
|
||||
microphonePermission.state !== 'prompt'
|
||||
) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const handleMicrophoneChange = (e: Event) => {
|
||||
const target = e.target as PermissionStatus
|
||||
permissionsStore.microphonePermission = target.state
|
||||
|
||||
if (
|
||||
intervalId &&
|
||||
target.state !== 'prompt' &&
|
||||
microphonePermission.state !== 'prompt'
|
||||
) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
cameraPermission.addEventListener('change', handleCameraChange)
|
||||
microphonePermission.addEventListener('change', handleMicrophoneChange)
|
||||
|
||||
cleanup = () => {
|
||||
cameraPermission.removeEventListener('change', handleCameraChange)
|
||||
microphonePermission.removeEventListener(
|
||||
'change',
|
||||
handleMicrophoneChange
|
||||
)
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = undefined
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error('Error checking permissions:', error)
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
permissionsStore.isLoading = false
|
||||
}
|
||||
}
|
||||
let statuses: PermissionStatus[] = []
|
||||
let cancelled = false
|
||||
if (navigator.permissions) {
|
||||
Promise.all([
|
||||
navigator.permissions.query({ name: 'camera' as PermissionName }),
|
||||
navigator.permissions.query({ name: 'microphone' as PermissionName }),
|
||||
])
|
||||
.then((results) => {
|
||||
if (cancelled) return
|
||||
statuses = results
|
||||
statuses.forEach((s) => s.addEventListener('change', sync))
|
||||
})
|
||||
.catch(() => {
|
||||
// Query unsupported: devicechange/focus + gUM outcomes cover it.
|
||||
})
|
||||
}
|
||||
checkPermissions()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
cleanup?.()
|
||||
cancelled = true
|
||||
navigator.mediaDevices?.removeEventListener?.('devicechange', sync)
|
||||
window.removeEventListener('focus', sync)
|
||||
statuses.forEach((s) => s.removeEventListener('change', sync))
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
||||
import { useEffect } from 'react'
|
||||
import { closeSidePanel } from '@/stores/layout'
|
||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
@@ -205,7 +206,7 @@ export const Admin = () => {
|
||||
patchRoom({
|
||||
roomId,
|
||||
room: { access_level: value as ApiAccessLevel },
|
||||
}).catch((e) => console.error(e))
|
||||
}).catch((e) => reportError('generic_failure', e))
|
||||
}
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -9,28 +9,16 @@ import { useSnapshot } from 'valtio'
|
||||
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
|
||||
import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
|
||||
import posthog from 'posthog-js'
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
||||
|
||||
export const ConnectionObserver = () => {
|
||||
const room = useRoomContext()
|
||||
const connectionStartTimeRef = useRef<number | null>(null)
|
||||
|
||||
const { data } = useConfig()
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
||||
const isMobile = isMobileBrowser()
|
||||
|
||||
const isAdvancedConnectionObserverEnabled =
|
||||
!isMobile && isAnalyticsEnabled && featureEnabled
|
||||
|
||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||
|
||||
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
|
||||
@@ -80,100 +68,6 @@ export const ConnectionObserver = () => {
|
||||
userPreferencesSnap.is_idle_disconnect_modal_enabled,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdvancedConnectionObserverEnabled) return
|
||||
if (!room) return
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const pollCandidate = async (
|
||||
label: 'publisher' | 'subscriber',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pc?: any
|
||||
) => {
|
||||
if (!pc) return
|
||||
|
||||
let stats: RTCStatsReport
|
||||
try {
|
||||
stats = await pc.getStats()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
stats.forEach((report: any) => {
|
||||
if (
|
||||
report.type === 'candidate-pair' &&
|
||||
report.state === 'succeeded' &&
|
||||
report.nominated
|
||||
) {
|
||||
const remoteCandidate = stats.get(report.remoteCandidateId)
|
||||
if (!remoteCandidate) return
|
||||
|
||||
const next = {
|
||||
type: remoteCandidate.candidateType,
|
||||
address: remoteCandidate.address,
|
||||
protocol: remoteCandidate.protocol,
|
||||
}
|
||||
|
||||
const current = connectionObserverStore[label]
|
||||
|
||||
const hasChanged =
|
||||
current?.type !== next.type ||
|
||||
current?.address !== next.address ||
|
||||
current?.protocol !== next.protocol
|
||||
|
||||
if (hasChanged) {
|
||||
connectionObserverStore[label] = next
|
||||
const key = `${label}ChangesCount` as const
|
||||
connectionObserverStore[key] =
|
||||
(connectionObserverStore[key] || 0) + 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
const publisher = room.engine?.pcManager?.publisher
|
||||
const subscriber = room.engine?.pcManager?.subscriber
|
||||
|
||||
await Promise.all([
|
||||
pollCandidate('publisher', publisher),
|
||||
pollCandidate('subscriber', subscriber),
|
||||
])
|
||||
}
|
||||
|
||||
const startPolling = async () => {
|
||||
if (interval) return // prevent duplicates
|
||||
|
||||
// Initial snapshot
|
||||
await poll()
|
||||
|
||||
interval = setInterval(poll, CANDIDATE_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (!interval) return
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
|
||||
room.on(RoomEvent.Connected, startPolling)
|
||||
room.on(RoomEvent.Reconnected, startPolling)
|
||||
|
||||
room.on(RoomEvent.Reconnecting, stopPolling)
|
||||
room.on(RoomEvent.Disconnected, stopPolling)
|
||||
|
||||
return () => {
|
||||
stopPolling()
|
||||
|
||||
room.off(RoomEvent.Connected, startPolling)
|
||||
room.off(RoomEvent.Reconnected, startPolling)
|
||||
room.off(RoomEvent.Reconnecting, stopPolling)
|
||||
room.off(RoomEvent.Disconnected, stopPolling)
|
||||
}
|
||||
}, [room, isAdvancedConnectionObserverEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
@@ -182,23 +76,23 @@ export const ConnectionObserver = () => {
|
||||
// total session duration from first connect to final disconnect.
|
||||
if (connectionStartTimeRef.current != null) return
|
||||
connectionStartTimeRef.current = Date.now()
|
||||
posthog.capture('connection-event')
|
||||
void captureMediaEvent('connection-event', {})
|
||||
}
|
||||
|
||||
const handleReconnect = () => {
|
||||
posthog.capture('reconnect-event')
|
||||
captureEvent('reconnect-event')
|
||||
}
|
||||
|
||||
const handleReconnected = () => {
|
||||
posthog.capture('reconnected-event')
|
||||
captureEvent('reconnected-event')
|
||||
}
|
||||
|
||||
const handleSignalingConnect = () => {
|
||||
posthog.capture('signaling-connect-event')
|
||||
captureEvent('signaling-connect-event')
|
||||
}
|
||||
|
||||
const handleSignalingReconnect = () => {
|
||||
posthog.capture('signaling-reconnect-event')
|
||||
captureEvent('signaling-reconnect-event')
|
||||
}
|
||||
|
||||
const handleDisconnect = (
|
||||
@@ -206,7 +100,7 @@ export const ConnectionObserver = () => {
|
||||
) => {
|
||||
const connectionEndTime = Date.now()
|
||||
|
||||
posthog.capture('disconnect-event', {
|
||||
captureEvent('disconnect-event', {
|
||||
// Calculate total session duration from first connection to final disconnect
|
||||
// This duration is sensitive to refreshing the page.
|
||||
sessionDuration: connectionStartTimeRef.current
|
||||
|
||||
@@ -14,7 +14,7 @@ export const Info = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||
|
||||
const data = useRoomData()
|
||||
const roomUrl = getRouteUrl('room', data?.slug)
|
||||
const roomUrl = data?.slug ? getRouteUrl('room', data.slug) : ''
|
||||
|
||||
const telephony = useTelephony()
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { A, Button, Dialog, P } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { getOS, type OS } from '@/utils/os'
|
||||
|
||||
const SCREEN_CAPTURE_SETTINGS_LINKS: Partial<Record<OS, string>> = {
|
||||
macos:
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
||||
windows: 'ms-settings:privacy-graphicscaptureprogrammatic',
|
||||
}
|
||||
|
||||
// todo - refactor it into a generic system
|
||||
export const ScreenShareErrorModal = ({
|
||||
@@ -11,7 +18,8 @@ export const ScreenShareErrorModal = ({
|
||||
onClose: () => void
|
||||
}) => {
|
||||
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 (
|
||||
<Dialog
|
||||
@@ -26,15 +34,16 @@ export const ScreenShareErrorModal = ({
|
||||
<>
|
||||
<P>
|
||||
{t('message')}{' '}
|
||||
{isMac && (
|
||||
{settingsHref && (
|
||||
<>
|
||||
{t('macInstructions')}{' '}
|
||||
{t('settingsInstructions')}{' '}
|
||||
<A
|
||||
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
||||
href={settingsHref}
|
||||
target="_blank"
|
||||
color="primary"
|
||||
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
|
||||
aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
|
||||
>
|
||||
{t('macSystemPreferences')}
|
||||
{t(`settingsLabel.${os}`)}
|
||||
</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
|
||||
}
|
||||
+11
-6
@@ -1,5 +1,5 @@
|
||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||
import posthog from 'posthog-js'
|
||||
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||
import {
|
||||
FilesetResolver,
|
||||
ImageSegmenter,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
} from '.'
|
||||
import { captureEvent } from '@/features/analytics/telemetry.ts'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
const PROCESSING_HEIGHT = 144
|
||||
@@ -85,7 +86,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.sourceSettings = this.source!.getSettings()
|
||||
this.videoElement = opts.element as HTMLVideoElement
|
||||
|
||||
this._initVirtualBackgroundImage()
|
||||
await this._initVirtualBackgroundImage()
|
||||
this._createMainCanvas()
|
||||
this._createMaskCanvas()
|
||||
|
||||
@@ -100,10 +101,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
await this.initSegmenter()
|
||||
this._initWorker()
|
||||
|
||||
posthog.capture('firefox-blurring-init')
|
||||
captureEvent('firefox-blurring-init', {})
|
||||
}
|
||||
|
||||
_initVirtualBackgroundImage() {
|
||||
async _initVirtualBackgroundImage() {
|
||||
if (this.options.type !== 'virtual') {
|
||||
throw new Error(
|
||||
'Virtual background is only supported for virtual background'
|
||||
@@ -115,15 +116,19 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.virtualBackgroundImage &&
|
||||
this.virtualBackgroundImage.src !== this.options.imagePath
|
||||
if (this.options.imagePath || needsUpdate) {
|
||||
// Embedded (token) mode: img.src cannot carry the Authorization
|
||||
// header, resolve the media to a blob object URL first. Identity
|
||||
// in regular mode.
|
||||
const imagePath = await resolveMediaUrl(this.options.imagePath!)
|
||||
this.virtualBackgroundImage = document.createElement('img')
|
||||
this.virtualBackgroundImage.crossOrigin = 'anonymous'
|
||||
this.virtualBackgroundImage.src = this.options.imagePath!
|
||||
this.virtualBackgroundImage.src = imagePath
|
||||
}
|
||||
}
|
||||
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
this.options = opts
|
||||
this._initVirtualBackgroundImage()
|
||||
await this._initVirtualBackgroundImage()
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
FilesetResolver,
|
||||
FaceLandmarker,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
} from '.'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
const PROCESSING_WIDTH = 256 * 3
|
||||
const PROCESSING_HEIGHT = 144 * 3
|
||||
@@ -101,7 +101,7 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
||||
await this.initFaceLandmarker()
|
||||
this._initWorker()
|
||||
|
||||
posthog.capture('face-landmarks-init')
|
||||
captureEvent('face-landmarks-init', {})
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
|
||||
+14
-1
@@ -1,4 +1,5 @@
|
||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||
import { resolveMediaUrl } from '@/features/files/utils/resolveMediaUrl'
|
||||
import {
|
||||
ProcessorWrapper,
|
||||
BackgroundProcessor,
|
||||
@@ -47,7 +48,16 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
}
|
||||
|
||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||
return this.processor.init(opts)
|
||||
await this.processor.init(opts)
|
||||
// Embedded (token) mode: the constructor passed the raw imagePath,
|
||||
// whose native load cannot carry the Authorization header. Swap it
|
||||
// for a resolved blob object URL. No-op in regular mode.
|
||||
if (this.opts.type === 'virtual') {
|
||||
const imagePath = await resolveMediaUrl(this.opts.imagePath)
|
||||
if (imagePath !== this.opts.imagePath) {
|
||||
await this.processor.updateTransformerOptions({ imagePath })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||
@@ -59,6 +69,9 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
}
|
||||
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
if (opts.type === 'virtual') {
|
||||
opts = { ...opts, imagePath: await resolveMediaUrl(opts.imagePath) }
|
||||
}
|
||||
this.opts = opts
|
||||
|
||||
const newProcessorType =
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ProcessorWrapper } from '@livekit/track-processors'
|
||||
import {
|
||||
ProcessorWrapper,
|
||||
supportsBackgroundProcessors,
|
||||
} from '@livekit/track-processors'
|
||||
import type { Track, TrackProcessor } from 'livekit-client'
|
||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||
@@ -10,7 +13,7 @@ export const SELFIE_SEGMENTER_MODEL_PATH =
|
||||
export const FACE_LANDMARKS_MODEL_PATH =
|
||||
'/assets/mediapipe/models/face_landmarker.task'
|
||||
|
||||
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
|
||||
export const MEDIAPIPE_PATH_WASM = `/assets/mediapipe/wasm/${__MEDIAPIPE_VERSION__}`
|
||||
|
||||
export enum ProcessorType {
|
||||
BLUR = 'blur',
|
||||
@@ -34,7 +37,9 @@ export class BackgroundProcessorFactory {
|
||||
}
|
||||
|
||||
static isSupported() {
|
||||
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
|
||||
return (
|
||||
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
|
||||
)
|
||||
}
|
||||
|
||||
static getProcessor(
|
||||
@@ -45,7 +50,7 @@ export class BackgroundProcessorFactory {
|
||||
|
||||
if (!isBlur && !isVirtual) return undefined
|
||||
|
||||
if (ProcessorWrapper.isSupported) {
|
||||
if (supportsBackgroundProcessors()) {
|
||||
return new UnifiedBackgroundTrackProcessor(config)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RiCameraSwitchLine } from '@remixicon/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ButtonProps } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
enum FacingMode {
|
||||
USER = 'user',
|
||||
@@ -103,7 +104,11 @@ export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
|
||||
setActiveMediaDevice(device.deviceId)
|
||||
setFacingMode(target)
|
||||
} else {
|
||||
console.error('Cannot get user device with facingMode ' + target)
|
||||
reportError(
|
||||
'device_switch_failure',
|
||||
new Error('Cannot get user device with facingMode ' + target),
|
||||
{ path: 'switch_device', kind: 'videoinput', facing_mode: target }
|
||||
)
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
+13
-2
@@ -1,8 +1,12 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||
import {
|
||||
useLocalParticipant,
|
||||
useTrackToggle,
|
||||
UseTrackToggleProps,
|
||||
} from '@livekit/components-react'
|
||||
import { Button, Popover } from '@/primitives'
|
||||
import { RiArrowUpSLine } from '@remixicon/react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { LocalAudioTrack, Track } from 'livekit-client'
|
||||
|
||||
import { ToggleDevice } from './ToggleDevice'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -51,6 +55,12 @@ export const AudioDevicesControl = ({
|
||||
...props,
|
||||
})
|
||||
|
||||
const { microphoneTrack } = useLocalParticipant()
|
||||
const localAudioTrack =
|
||||
microphoneTrack?.track instanceof LocalAudioTrack
|
||||
? microphoneTrack.track
|
||||
: undefined
|
||||
|
||||
const kind = 'audioinput'
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
||||
@@ -111,6 +121,7 @@ export const AudioDevicesControl = ({
|
||||
context="room"
|
||||
kind={kind}
|
||||
id={audioDeviceId}
|
||||
track={localAudioTrack}
|
||||
onSubmit={saveAudioInputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+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 { 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 label = tooltip ?? t('tooltip')
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -17,9 +26,9 @@ export const PermissionNeededButton = () => {
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
aria-label={t('ariaLabel')}
|
||||
tooltip={t('tooltip')}
|
||||
onPress={() => openPermissionsDialog()}
|
||||
aria-label={tooltip ? label : t('ariaLabel')}
|
||||
tooltip={label}
|
||||
onPress={onPress ?? (() => openPermissionsDialog())}
|
||||
variant="permission"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,12 @@ import { useEffect, useMemo } from 'react'
|
||||
import { Select, SelectProps } from '@/primitives/Select'
|
||||
import type { Placement } from '@react-types/overlays'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
|
||||
import type { LocalAudioTrack } from 'livekit-client'
|
||||
import { AudioLevelGauge } from './AudioLevelGauge'
|
||||
import { OutputSoundTester } from './OutputSoundTester'
|
||||
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||
|
||||
type DeviceItems = Array<{ value: string; label: string }>
|
||||
|
||||
@@ -18,6 +23,7 @@ type SelectDeviceProps = {
|
||||
onSubmit?: (id: string) => void
|
||||
kind: MediaDeviceKind
|
||||
context?: 'join' | 'room'
|
||||
track?: LocalAudioTrack
|
||||
}
|
||||
|
||||
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
|
||||
@@ -28,6 +34,7 @@ const SelectDevicePermissions = <T extends string | number>({
|
||||
kind,
|
||||
onSubmit,
|
||||
iconComponent,
|
||||
track,
|
||||
...props
|
||||
}: SelectDevicePermissionsProps<T>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
@@ -74,6 +81,16 @@ const SelectDevicePermissions = <T extends string | number>({
|
||||
await setActiveMediaDevice(key as string)
|
||||
onSubmit?.(key as string)
|
||||
}}
|
||||
menuFooter={
|
||||
kind === 'audioinput' ? (
|
||||
<AudioLevelGauge track={track} variant={props.variant} />
|
||||
) : kind === 'audiooutput' && canTestAudioOutput() ? (
|
||||
<OutputSoundTester
|
||||
sinkId={selectedKey as string}
|
||||
variant={props.variant}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -84,6 +101,7 @@ export const SelectDevice = ({
|
||||
onSubmit,
|
||||
kind,
|
||||
context = 'join',
|
||||
track,
|
||||
}: SelectDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
@@ -96,6 +114,25 @@ export const SelectDevice = ({
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceMissing = useDeviceMissing(kind)
|
||||
|
||||
if (deviceMissing) {
|
||||
return (
|
||||
<Select
|
||||
aria-label={t(`NotFound.title.${kind}`, {
|
||||
keyPrefix: 'mediaErrorDialog',
|
||||
})}
|
||||
label=""
|
||||
isDisabled={true}
|
||||
items={[]}
|
||||
placeholder={t(`NotFound.title.${kind}`, {
|
||||
keyPrefix: 'mediaErrorDialog',
|
||||
})}
|
||||
iconComponent={deviceIcons.select}
|
||||
{...contextProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (cannotUseDevice) {
|
||||
return (
|
||||
@@ -116,6 +153,7 @@ export const SelectDevice = ({
|
||||
id={id}
|
||||
onSubmit={onSubmit}
|
||||
kind={kind}
|
||||
track={track}
|
||||
iconComponent={deviceIcons.select}
|
||||
{...contextProps}
|
||||
/>
|
||||
|
||||
+68
-11
@@ -1,7 +1,7 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PermissionNeededButton } from './PermissionNeededButton'
|
||||
@@ -12,10 +12,16 @@ import {
|
||||
useMaybeRoomContext,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { MediaDeviceFailure } from 'livekit-client'
|
||||
import { MediaDeviceErrorAlert } from '@/features/rooms/components/MediaDeviceErrorAlert'
|
||||
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
import type {
|
||||
@@ -90,9 +96,45 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceMissing = useDeviceMissing(kind)
|
||||
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||
const silentMicWarning =
|
||||
kind === 'audioinput' &&
|
||||
silentMicStatus === 'silent' &&
|
||||
!cannotUseDevice &&
|
||||
!deviceMissing
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const isRequestingPermission = useRef(false)
|
||||
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
|
||||
|
||||
const onPress = async () => {
|
||||
if (!enabled && deviceMissing) {
|
||||
setShowDeviceNotFound(true)
|
||||
return
|
||||
}
|
||||
if (!cannotUseDevice) {
|
||||
toggle()
|
||||
return
|
||||
}
|
||||
if (isRequestingPermission.current) return
|
||||
isRequestingPermission.current = true
|
||||
try {
|
||||
const granted = await requestDevicePermission(
|
||||
kind,
|
||||
context === 'join' ? 'join_preview' : 'room'
|
||||
)
|
||||
if (granted) {
|
||||
toggle()
|
||||
} else {
|
||||
openPermissionsDialog(kind)
|
||||
}
|
||||
} finally {
|
||||
isRequestingPermission.current = false
|
||||
}
|
||||
}
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: deviceShortcut?.id,
|
||||
handler: async () => {
|
||||
@@ -139,7 +181,20 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{cannotUseDevice && <PermissionNeededButton />}
|
||||
{(cannotUseDevice || deviceMissing) && (
|
||||
<PermissionNeededButton
|
||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||
onPress={
|
||||
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{silentMicWarning && (
|
||||
<PermissionNeededButton
|
||||
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
|
||||
onPress={openSilentMicDialog}
|
||||
/>
|
||||
)}
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
isDisabled={isDisabled}
|
||||
@@ -147,23 +202,25 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
isDisabled || cannotUseDevice || !enabled ? errorVariant : variant
|
||||
}
|
||||
shySelected
|
||||
onPress={() => {
|
||||
if (cannotUseDevice) {
|
||||
openPermissionsDialog(kind)
|
||||
}
|
||||
toggle()
|
||||
}}
|
||||
onPress={onPress}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={
|
||||
cannotUseDevice
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
deviceMissing
|
||||
? t(`deviceNotFound.${kind}`)
|
||||
: cannotUseDevice
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
}
|
||||
{...computedToggleButtonProps}
|
||||
{...overrideToggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
<MediaDeviceErrorAlert
|
||||
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
|
||||
kind={kind}
|
||||
onClose={() => setShowDeviceNotFound(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/primitives'
|
||||
import { RiPhoneFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ConnectionState } from 'livekit-client'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const LeaveButton = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
||||
@@ -15,11 +16,11 @@ export const LeaveButton = () => {
|
||||
tooltip={t('leave')}
|
||||
aria-label={t('leave')}
|
||||
onPress={() => {
|
||||
room
|
||||
.disconnect(true)
|
||||
.catch((e) =>
|
||||
console.error('An error occurred while disconnecting:', e)
|
||||
)
|
||||
room.disconnect(true).catch((e) =>
|
||||
reportError('disconnect_failure', e, {
|
||||
context: 'An error occurred while disconnecting:',
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="controls-leave"
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user