mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-27 10:46:47 +00:00
Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26cb99b81c | |||
| 5bc04409de | |||
| 78a06ab55f | |||
| 1443ef12eb | |||
| f1d3799434 | |||
| e59aaaa998 | |||
| 76a24d4787 | |||
| 943b81676b | |||
| 7369379106 | |||
| c02d54b6ff | |||
| cec2eb5a10 | |||
| 2858d141f4 | |||
| f10429581b | |||
| b3369cddf7 | |||
| cfffadc780 | |||
| 4ae31b3297 | |||
| 954991c7c3 | |||
| 6e2a7f0ca6 | |||
| 24404e4ea2 | |||
| f18d784615 | |||
| 1bfe6b8977 | |||
| 84845709d0 | |||
| e000377b5c | |||
| a9fa3eb4ba | |||
| 2ec3f54532 | |||
| d723c14d84 | |||
| e5f0b1c202 | |||
| 9e0d57a8c6 | |||
| 7ba0803b71 | |||
| beacfc3d3f | |||
| 52e5d99e83 | |||
| 15ca2b41b4 | |||
| e34f3dd219 | |||
| feb573551f | |||
| 1d0a0cc637 | |||
| eae93f771f | |||
| 45175a2a54 | |||
| 8b22059b18 | |||
| 87dbd8069d | |||
| c7420c59a3 | |||
| f46babfcbd | |||
| 7c465f2148 | |||
| d005f202c6 | |||
| a82023f8b0 | |||
| cc9dae66db | |||
| 8d000fc6d9 | |||
| b7abd0ae6e | |||
| 40e4f17c65 | |||
| 77c5329f8a | |||
| c8ec1c8a9d | |||
| 01e004e272 | |||
| cbfb97eb54 | |||
| ac503b3ae5 | |||
| 52f119db02 | |||
| 387ae17c22 | |||
| 1eb6f0b9e7 | |||
| 047a4c9f3f | |||
| 6c4f0632b8 | |||
| ff7a1a4f33 | |||
| 7d1ce5f215 | |||
| 22ab89994b | |||
| e1a28f315d | |||
| dffcb83fff | |||
| c838229ec9 | |||
| ab40ec365d | |||
| 199c0297d4 | |||
| 089db20a2e | |||
| f0c08bea92 | |||
| 03e90b6178 | |||
| c53a2f8af4 | |||
| 7461cd28ce | |||
| 5723f29cef | |||
| 68a5e84f5d | |||
| b84ee74ee2 | |||
| 0dd2478c3e | |||
| 8f27b89d21 | |||
| b780d2845a | |||
| 751d029ac9 | |||
| aaa51a4457 | |||
| c8a3ef6f61 | |||
| 5d50671b3c | |||
| 8615bf879c | |||
| 186d16c46f | |||
| fb3ee56702 | |||
| 48c0cb320e | |||
| d810c9e0de | |||
| 134d9a188f | |||
| ea7188059d | |||
| b8958e6e87 | |||
| 23bb3c39d0 | |||
| e0ff28ed48 | |||
| 61e8b597dc | |||
| f3626a2dc6 | |||
| 41e937c1f1 | |||
| d988c72208 |
@@ -75,6 +75,7 @@ db.sqlite3
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
.cursor/
|
||||
*.iml
|
||||
.devcontainer
|
||||
|
||||
|
||||
+115
@@ -8,6 +8,121 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) share OneToOneFocusLayout between PiP and main room
|
||||
|
||||
## [1.29.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(any) let any authenticated user manage the lobby on trusted rooms
|
||||
|
||||
### Changed
|
||||
|
||||
- 📱(frontend) collapse mobile control bar items on narrow viewports
|
||||
- 📱(frontend) stack idle modal buttons in a column on mobile
|
||||
- 📱(frontend) improve feedback screen responsiveness on mobile
|
||||
- ⬆️(frontend) upgrade @fontsource-variable/atkinson-hyperlegible-next
|
||||
- ⬆️(frontend) upgrade i18next-resources-to-backend from 1.2.1 to 1.2.3
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
|
||||
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
|
||||
- ⚡️(frontend) apply frugal constraint to the active meeting audio track
|
||||
- ⚡️(backend) replace blocking Redis KEYS with cursor-based SCAN
|
||||
- ✨(summary) add hostname to analytics properties
|
||||
|
||||
## [1.28.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 📈(frontend) track errors when starting or stopping a recording
|
||||
- 🚸(frontend) explain camera-in-use failures on the join screen
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(backend) accept form-urlencoded on the user token endpoint
|
||||
- ✨(summary) configurable s3 region
|
||||
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
|
||||
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
|
||||
- 💄(frontend) increase the blur intensity
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📝(docs) fix minor typos in comments and docstrings
|
||||
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
|
||||
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
|
||||
- 🐛(frontend) treat client-initiated connect aborts as events
|
||||
- 🐛(frontend) use state instead of a ref for MoreControls container
|
||||
- 🐛(frontend) stop init_virtual_background from firing on blur updates
|
||||
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
|
||||
- 🐛(frontend) fix joined notification tile no longer rendering properly
|
||||
- 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
|
||||
- 🐛(frontend) handle Firefox/Windows AbortError on device start
|
||||
- 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
|
||||
- 🔇(frontend) suppress leaked WebSocket error events from livekit-client
|
||||
|
||||
## [1.27.0] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔥(frontend) drop unused vendored ConnectionObserver
|
||||
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📈(frontend) downgrade unreachable external home URL from error to event
|
||||
- 🐛(frontend) handle 401 responses when syncing user preferences
|
||||
- 🐛(frontend) harden speaker test against missing sinks and play errors
|
||||
- 🐛(frontend) implement hysteresis band for the control bar layout
|
||||
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
|
||||
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
|
||||
- 🐛(frontend) stop reporting screen-share denials as errors
|
||||
- 🐛(frontend) generalize screen-share error modal beyond macOS
|
||||
- 📈(frontend) stop double-reporting media device failures
|
||||
|
||||
## [1.26.0] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- 📈(frontend) capture media diagnostics on media errors
|
||||
- ✨(frontend) add an audio gauge to the microphone select menu
|
||||
- ✨(frontend) add a sound tester to the output select menu
|
||||
- ✨(frontend) prompt for permissions when toggling a denied device
|
||||
- ⚗️(frontend) capture console.error in PostHog
|
||||
- 📈(frontend) snapshot media devices on the happy path
|
||||
- 🚸(frontend) guide users when the OS blocks browser media access
|
||||
- ✨(frontend) add a silent-microphone watcher on join and room screens
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) encapsulate error tracking behind a telemetry module
|
||||
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
|
||||
- 🔧(frontend) sync persisted device ids with the actual selected devices
|
||||
- 💄(frontend) hide the ProConnect button on narrow viewports
|
||||
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
|
||||
- 🐛(frontend) fix permission store regression
|
||||
- 🐛(frontend) handle missing device errors gracefully
|
||||
- 🐛(frontend) display the meeting id in the join screen page title
|
||||
|
||||
## [1.25.2] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) serve MediaPipe assets under a versioned path
|
||||
- 🐛(frontend) harmonize cache configuration for MediaPipe assets
|
||||
|
||||
## [1.25.1] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚑️(frontend) fix background crash from MediaPipe WASM version mismatch
|
||||
|
||||
## [1.25.0] - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
Security is very important to us.
|
||||
|
||||
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
|
||||
If you have any issue regarding security, please disclose the information responsibly by submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
|
||||
|
||||
We appreciate your effort to make Visio more secure.
|
||||
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:18
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,10 +50,24 @@ paths:
|
||||
|
||||
The application must be authorized for the user's email domain.
|
||||
The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
|
||||
|
||||
Request parameters may be sent either as "application/x-www-form-urlencoded"
|
||||
(as specified by RFC 6749 for OAuth 2.0 token endpoints) or as "application/json".
|
||||
operationId: generateToken
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenRequest'
|
||||
examples:
|
||||
tokenRequest:
|
||||
summary: Request token for user delegation
|
||||
value:
|
||||
client_id: "550e8400-e29b-41d4-a716-446655440000"
|
||||
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
|
||||
grant_type: "client_credentials"
|
||||
scope: "user@example.com"
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenRequest'
|
||||
@@ -117,6 +131,19 @@ paths:
|
||||
summary: Domain not authorized
|
||||
value:
|
||||
error: "This application is not authorized for this email domain."
|
||||
'415':
|
||||
description: |
|
||||
Unsupported media type. The request body must be sent as
|
||||
"application/x-www-form-urlencoded" or "application/json".
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
examples:
|
||||
unsupportedMediaType:
|
||||
summary: Unsupported request content type
|
||||
value:
|
||||
detail: 'Unsupported media type "text/plain" in request.'
|
||||
|
||||
/rooms:
|
||||
get:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.25.0"
|
||||
version = "1.29.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.0"
|
||||
version = "1.29.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.http import Http404
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
from ..models import RoleChoices, RoomAccessLevel
|
||||
from ..services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
@@ -198,3 +198,48 @@ class IsPresentInMeeting(permissions.BasePermission):
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
|
||||
class CanManageLobby(permissions.BasePermission):
|
||||
"""Grant lobby management (list/accept/deny waiting participants).
|
||||
|
||||
- Room admins/owners can always manage the lobby.
|
||||
- When the room access level is TRUSTED, any authenticated user who is
|
||||
currently connected to the meeting can manage the lobby. Presence is
|
||||
verified cache-first (Redis), falling back to the LiveKit API.
|
||||
|
||||
Access level is always read fresh from the DB; only presence is cached,
|
||||
so changing the room to RESTRICTED takes effect immediately.
|
||||
"""
|
||||
|
||||
message = "You are not allowed to manage this room's lobby."
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
def has_object_permission(self, request, view, obj): # noqa: PLR0911
|
||||
"""Check privileges first, then the trusted-room presence path."""
|
||||
user = request.user
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Product choice: lobby management is reserved for session-authenticated
|
||||
# users with a real account, not holders of a LiveKit room token.
|
||||
if request.auth and hasattr(request.auth, "video"):
|
||||
return False
|
||||
|
||||
if obj.is_administrator_or_owner(user):
|
||||
return True
|
||||
|
||||
if obj.access_level != RoomAccessLevel.TRUSTED:
|
||||
return False
|
||||
|
||||
self.message = "You must be connected to the meeting to manage its lobby."
|
||||
|
||||
try:
|
||||
return ParticipantsManagement().check_if_in_meeting_cached(
|
||||
room_name=str(obj.pk), identity=str(user.sub)
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
@@ -536,7 +536,7 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="enter",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.CanManageLobby,
|
||||
],
|
||||
)
|
||||
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
|
||||
@@ -574,7 +574,7 @@ class RoomViewSet(
|
||||
methods=["GET"],
|
||||
url_path="waiting-participants",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.CanManageLobby,
|
||||
],
|
||||
)
|
||||
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
|
||||
|
||||
@@ -12,6 +12,9 @@ from rest_framework import decorators, mixins, viewsets
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
from rest_framework import (
|
||||
parsers as drf_parsers,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
@@ -41,6 +44,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
methods=["post"],
|
||||
url_path="token",
|
||||
url_name="token",
|
||||
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
|
||||
)
|
||||
@FeatureFlag.require("application")
|
||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||
|
||||
@@ -23,6 +23,7 @@ from core.recording.services.recording_events import (
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .presence import PresenceCache
|
||||
from .room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
@@ -99,6 +100,7 @@ class LiveKitEventsService:
|
||||
"egress_ended": self._handle_egress_ended,
|
||||
"room_started": self._handle_room_started,
|
||||
"room_finished": self._handle_room_finished,
|
||||
"participant_left": self._handle_participant_left,
|
||||
}
|
||||
|
||||
token_verifier = api.TokenVerifier(
|
||||
@@ -107,6 +109,7 @@ class LiveKitEventsService:
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
self.presence_cache = PresenceCache()
|
||||
self.sip_management = SIPManagement()
|
||||
self.recording_events = RecordingEventsService()
|
||||
|
||||
@@ -285,9 +288,31 @@ class LiveKitEventsService:
|
||||
f"Failed to delete sip dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
self.presence_cache.clear_room(room_id)
|
||||
|
||||
try:
|
||||
self.lobby_service.clear_room_cache(room_id)
|
||||
except Exception as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to clear room cache for room {room_id}"
|
||||
) from e
|
||||
|
||||
def _handle_participant_left(self, data):
|
||||
"""Handle 'participant_left': invalidate the presence cache.
|
||||
|
||||
Presence entries are created lazily (only for users who administrate
|
||||
the lobby of a trusted room), so for most participants this delete is
|
||||
a no-op DEL on a key that never existed. Eager invalidation shrinks
|
||||
the window during which a departed participant could still act on a
|
||||
trusted room's lobby (cache hit until TTL expiry). It is gated behind
|
||||
`PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be
|
||||
measured and the behaviour reverted independently of the feature.
|
||||
When disabled, invalidation relies on `room_finished` and the TTL.
|
||||
"""
|
||||
if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT:
|
||||
return
|
||||
|
||||
identity = data.participant.identity
|
||||
if not identity:
|
||||
return
|
||||
self.presence_cache.clear(data.room.name, identity)
|
||||
|
||||
@@ -270,7 +270,7 @@ class LobbyService:
|
||||
"""List all waiting participants for a room."""
|
||||
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = cache.keys(pattern)
|
||||
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
|
||||
|
||||
if not keys:
|
||||
return []
|
||||
@@ -345,13 +345,9 @@ class LobbyService:
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = cache.keys(pattern)
|
||||
|
||||
if not keys:
|
||||
return
|
||||
|
||||
cache.delete_many(keys)
|
||||
cache.delete_pattern(
|
||||
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
||||
)
|
||||
|
||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||
"""Clear a given participant entry from the cache for a specific room."""
|
||||
|
||||
@@ -20,6 +20,7 @@ from livekit.protocol.models import ParticipantInfo
|
||||
from core import utils
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .presence import PresenceCache
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -72,7 +73,9 @@ class ParticipantsManagement:
|
||||
|
||||
@async_to_sync
|
||||
async def remove(self, room_name: str, identity: str):
|
||||
"""Remove a participant from a room and clear their lobby cache."""
|
||||
"""Remove a participant from a room and clear their lobby/presence cache."""
|
||||
|
||||
PresenceCache().clear(room_name, identity)
|
||||
|
||||
try:
|
||||
LobbyService().clear_participant_cache(
|
||||
@@ -156,6 +159,30 @@ class ParticipantsManagement:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def check_if_in_meeting_cached(self, room_name: str, identity: str) -> bool:
|
||||
"""Cache-first variant of `check_if_in_meeting`.
|
||||
|
||||
Cache hit -> True without touching LiveKit.
|
||||
Cache miss -> ask LiveKit; memoize only positive answers.
|
||||
|
||||
Raises the same exceptions as `check_if_in_meeting` so callers keep
|
||||
failing closed the same way.
|
||||
"""
|
||||
if not room_name or not identity:
|
||||
return False
|
||||
|
||||
presence_cache = PresenceCache()
|
||||
|
||||
if presence_cache.is_marked_present(room_name, identity):
|
||||
return True
|
||||
|
||||
present = self.check_if_in_meeting(room_name=room_name, identity=identity)
|
||||
|
||||
if present:
|
||||
presence_cache.mark_present(room_name, identity)
|
||||
|
||||
return present
|
||||
|
||||
@async_to_sync
|
||||
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
|
||||
"""Check whether `identity` is currently a participant in `room_name`.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Presence cache.
|
||||
|
||||
Redis-backed memo of "this identity is currently connected to this room".
|
||||
|
||||
This module is intentionally a *pure cache store* with no dependency on other
|
||||
services, so that `participants_management` (which talks to LiveKit) can
|
||||
import it without creating an import cycle. The composition of "check cache,
|
||||
fall back to LiveKit" lives in
|
||||
`ParticipantsManagement.check_if_in_meeting_cached`.
|
||||
|
||||
Only positive answers are stored: a sticky negative would lock out someone
|
||||
who joins right after a miss for the whole TTL. The TTL is a safety net in
|
||||
case an invalidation webhook is lost.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from core.utils import CACHE_SCAN_ITERSIZE
|
||||
|
||||
|
||||
class PresenceCache:
|
||||
"""Store and invalidate (room, identity) presence entries."""
|
||||
|
||||
@staticmethod
|
||||
def _get_cache_key(room_id: UUID | str, identity: str) -> str:
|
||||
"""Cache key for a (room, identity) presence entry."""
|
||||
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
||||
|
||||
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
||||
"""Return True if a positive presence entry exists in cache."""
|
||||
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
||||
|
||||
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
||||
"""Record that `identity` is in `room_id`."""
|
||||
cache.set(
|
||||
self._get_cache_key(room_id, identity),
|
||||
True,
|
||||
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
||||
)
|
||||
|
||||
def clear(self, room_id: UUID | str, identity: str) -> None:
|
||||
"""Forget presence for one participant (e.g. on participant_left)."""
|
||||
cache.delete(self._get_cache_key(room_id, identity))
|
||||
|
||||
def clear_room(self, room_id: UUID | str) -> None:
|
||||
"""Forget presence for every participant of a room (on room_finished)."""
|
||||
cache.delete_pattern(
|
||||
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
||||
)
|
||||
@@ -389,7 +389,7 @@ def test_allow_participant_to_enter_anonymous():
|
||||
|
||||
def test_allow_participant_to_enter_non_owner():
|
||||
"""Non-privileged users should not be allowed to manage entry requests."""
|
||||
room = RoomFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -522,7 +522,7 @@ def test_list_waiting_participants_anonymous():
|
||||
|
||||
def test_list_waiting_participants_non_owner():
|
||||
"""Non-privileged users should not be allowed to list waiting participants."""
|
||||
room = RoomFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Trusted rooms: any authenticated participant present in the meeting can manage the lobby."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.presence import PresenceCache
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_trusted_room_present_user_can_list_waiting(mock_check):
|
||||
"""Authenticated + present in a trusted room -> 200, LiveKit asked once then cached."""
|
||||
mock_check.return_value = True
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
|
||||
assert client.get(url).status_code == 200
|
||||
assert client.get(url).status_code == 200
|
||||
assert mock_check.call_count == 1
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_trusted_room_absent_user_forbidden(mock_check):
|
||||
"""Authenticated but not connected to the meeting -> 403."""
|
||||
mock_check.return_value = False
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_restricted_room_present_user_forbidden(mock_check):
|
||||
"""Presence is not enough on a restricted room; LiveKit must not even be asked."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
assert response.status_code == 403
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_trusted_room_presence_cleared_after_leave(mock_check):
|
||||
"""Once the presence cache is cleared (participant_left), LiveKit is re-checked."""
|
||||
mock_check.return_value = True
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
|
||||
|
||||
assert client.get(url).status_code == 200
|
||||
PresenceCache().clear(room.id, str(user.sub))
|
||||
|
||||
mock_check.return_value = False
|
||||
assert client.get(url).status_code == 403
|
||||
assert mock_check.call_count == 2
|
||||
|
||||
|
||||
def test_trusted_room_anonymous_forbidden():
|
||||
"""Anonymous users never manage the lobby."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
response = APIClient().get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_trusted_room_present_user_can_accept_entry(mock_check):
|
||||
"""Authenticated + present in a trusted room can accept a waiting participant."""
|
||||
mock_check.return_value = True
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
)
|
||||
# Permission passed; 404 because that participant isn't actually waiting.
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"message": "Participant not found."}
|
||||
@@ -854,3 +854,31 @@ def test_receive_ignores_connection_test_room(
|
||||
|
||||
mock_handle_room_started.assert_not_called()
|
||||
mock_handle_room_finished.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.presence.cache.delete")
|
||||
def test_participant_left_clearing_gated_by_setting(mock_delete, service, settings):
|
||||
"""PRESENCE_CLEAR_ON_PARTICIPANT_LEFT toggles eager presence invalidation."""
|
||||
data = mock.Mock()
|
||||
data.room.name = "room-name"
|
||||
data.participant.identity = "user-sub"
|
||||
|
||||
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = False
|
||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
|
||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
||||
mock_delete.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.presence.cache.delete")
|
||||
def test_participant_left_without_identity_is_ignored(mock_delete, service, settings):
|
||||
"""No cache operation when the webhook carries no identity."""
|
||||
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
|
||||
data = mock.Mock()
|
||||
data.room.name = "room-name"
|
||||
data.participant.identity = ""
|
||||
|
||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
@@ -24,6 +24,7 @@ from core.services.lobby import (
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.presence import CACHE_SCAN_ITERSIZE
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -578,14 +579,14 @@ def test_get_participant_parsing_error(
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants when none exist."""
|
||||
mock_cache.keys.return_value = []
|
||||
mock_cache.iter_keys.return_value = []
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == []
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_not_called()
|
||||
|
||||
|
||||
@@ -594,7 +595,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
"""Test listing waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.keys.return_value = [cache_key]
|
||||
mock_cache.iter_keys.return_value = [cache_key]
|
||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
@@ -603,7 +604,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
assert result[0]["status"] == "waiting"
|
||||
assert result[0]["username"] == "test-username"
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||
|
||||
|
||||
@@ -628,7 +629,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"color": "#654321",
|
||||
}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
@@ -646,7 +647,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
assert all(p["status"] == "waiting" for p in result)
|
||||
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@@ -655,7 +656,7 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with corrupted data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.keys.return_value = [cache_key]
|
||||
mock_cache.iter_keys.return_value = [cache_key]
|
||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
@@ -680,7 +681,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
|
||||
corrupted_participant = {"invalid": "data"}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: corrupted_participant,
|
||||
cache_key2: valid_participant,
|
||||
@@ -699,7 +700,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
|
||||
# Verify both cache keys were queried
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@@ -723,7 +724,7 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||
"color": "#654321",
|
||||
}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for the presence cache and the cached presence check."""
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.presence import PresenceCache
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_presence_cache_hit_skips_livekit(mock_check):
|
||||
"""A cached positive answer must not call LiveKit."""
|
||||
room_id, identity = str(uuid4()), "user-sub"
|
||||
PresenceCache().mark_present(room_id, identity)
|
||||
|
||||
assert (
|
||||
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) is True
|
||||
)
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_presence_cache_miss_calls_livekit_and_caches_positive(mock_check):
|
||||
"""On a miss, LiveKit is asked once and a positive answer is memoized."""
|
||||
mock_check.return_value = True
|
||||
room_id, identity = str(uuid4()), "user-sub"
|
||||
service = ParticipantsManagement()
|
||||
|
||||
assert service.check_if_in_meeting_cached(room_id, identity) is True
|
||||
assert service.check_if_in_meeting_cached(room_id, identity) is True
|
||||
assert mock_check.call_count == 1
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_presence_negative_not_cached(mock_check):
|
||||
"""Negative answers are never memoized."""
|
||||
mock_check.return_value = False
|
||||
room_id, identity = str(uuid4()), "user-sub"
|
||||
service = ParticipantsManagement()
|
||||
|
||||
assert service.check_if_in_meeting_cached(room_id, identity) is False
|
||||
assert service.check_if_in_meeting_cached(room_id, identity) is False
|
||||
assert mock_check.call_count == 2
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_presence_errors_propagate_and_cache_nothing(mock_check):
|
||||
"""LiveKit errors propagate to the caller (which fails closed); nothing cached."""
|
||||
room_id, identity = str(uuid4()), "user-sub"
|
||||
|
||||
for exc in (ParticipantNotFoundException(), ParticipantsManagementException()):
|
||||
mock_check.side_effect = exc
|
||||
with pytest.raises(type(exc)):
|
||||
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity)
|
||||
assert cache.get(PresenceCache._get_cache_key(room_id, identity)) is None
|
||||
|
||||
|
||||
def test_presence_clear_and_clear_room():
|
||||
"""clear() removes one entry, clear_room() removes all entries of a room."""
|
||||
room_id, other_room = str(uuid4()), str(uuid4())
|
||||
presence = PresenceCache()
|
||||
presence.mark_present(room_id, "a")
|
||||
presence.mark_present(room_id, "b")
|
||||
presence.mark_present(other_room, "a")
|
||||
|
||||
presence.clear(room_id, "a")
|
||||
assert presence.is_marked_present(room_id, "a") is False
|
||||
assert presence.is_marked_present(room_id, "b") is True
|
||||
|
||||
presence.clear_room(room_id)
|
||||
assert presence.is_marked_present(room_id, "b") is False
|
||||
assert presence.is_marked_present(other_room, "a") is True
|
||||
|
||||
|
||||
def test_presence_clear_room_scans_in_pages():
|
||||
"""clear_room removes every match, even across several SCAN pages,
|
||||
and only within the room."""
|
||||
room_id, other_room = str(uuid4()), str(uuid4())
|
||||
presence = PresenceCache()
|
||||
for i in range(7):
|
||||
presence.mark_present(room_id, f"user-{i}")
|
||||
presence.mark_present(other_room, "user-0")
|
||||
|
||||
# An itersize smaller than the match count forces delete_pattern to
|
||||
# page through several SCAN cursors rather than finish in one pass.
|
||||
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
|
||||
presence.clear_room(room_id)
|
||||
|
||||
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
|
||||
assert presence.is_marked_present(other_room, "user-0") is True
|
||||
@@ -5,6 +5,7 @@ Tests for external API /token endpoint
|
||||
# pylint: disable=W0621
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
@@ -88,6 +89,155 @@ def test_api_applications_generate_token_success(settings):
|
||||
}
|
||||
|
||||
|
||||
def test_api_applications_generate_token_form_urlencoded(settings):
|
||||
"""The token endpoint should accept "application/x-www-form-urlencoded"
|
||||
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
|
||||
token endpoints, so that standard OAuth 2.0 client libraries work
|
||||
out of the box."""
|
||||
UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
(
|
||||
f"client_id={application.client_id}"
|
||||
f"&client_secret={plain_secret}"
|
||||
"&grant_type=client_credentials"
|
||||
"&scope=user%40example.com"
|
||||
),
|
||||
content_type="application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.data
|
||||
|
||||
response.data.pop("access_token")
|
||||
|
||||
assert response.data == {
|
||||
"token_type": "Bearer",
|
||||
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
"scope": "rooms:list rooms:create",
|
||||
}
|
||||
|
||||
|
||||
def test_api_applications_generate_token_form_urlencoded_invalid_credentials():
|
||||
"""Invalid credentials sent as form-urlencoded should be parsed and
|
||||
rejected with 401, proving the request body is properly decoded."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
urlencode(
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": "wrong-secret",
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
}
|
||||
),
|
||||
content_type="application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_form_urlencoded_missing_fields():
|
||||
"""Missing required fields in a form-urlencoded request should return
|
||||
a 400 validation error, like for JSON requests."""
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
urlencode({"grant_type": "client_credentials"}),
|
||||
content_type="application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
for field in ("client_id", "client_secret", "scope"):
|
||||
assert field in response.data
|
||||
|
||||
|
||||
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
|
||||
"""An unsupported grant_type sent as form-urlencoded should return 400."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
urlencode(
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "authorization_code",
|
||||
"scope": user.email,
|
||||
}
|
||||
),
|
||||
content_type="application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "grant_type" in response.data
|
||||
|
||||
|
||||
def test_api_applications_generate_token_form_urlencoded_special_characters():
|
||||
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
|
||||
client_secret should survive form-urlencoded decoding."""
|
||||
UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
|
||||
plain_secret = "s3cr3t&with=special+chars%42"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
urlencode(
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "user@example.com",
|
||||
}
|
||||
),
|
||||
content_type="application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.data
|
||||
|
||||
|
||||
def test_api_applications_generate_token_unsupported_media_type():
|
||||
"""Content types other than JSON and form-urlencoded should still be
|
||||
rejected with 415 Unsupported Media Type."""
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
"client_id=x&client_secret=y&grant_type=client_credentials&scope=a@b.co",
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
assert response.status_code == 415
|
||||
|
||||
|
||||
def test_api_applications_generate_token_invalid_client_id():
|
||||
"""Invalid client_id should return 401."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
|
||||
@@ -512,3 +512,6 @@ def build_telephony_config():
|
||||
"default_country": country,
|
||||
"international_phone_number": international,
|
||||
}
|
||||
|
||||
|
||||
CACHE_SCAN_ITERSIZE = 500
|
||||
|
||||
@@ -856,6 +856,15 @@ class Base(Configuration):
|
||||
)
|
||||
|
||||
# Lobby configurations
|
||||
PRESENCE_KEY_PREFIX = values.Value(
|
||||
"room_presence", environ_name="PRESENCE_KEY_PREFIX", environ_prefix=None
|
||||
)
|
||||
PRESENCE_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
3600, environ_name="PRESENCE_CACHE_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = values.BooleanValue(
|
||||
True, environ_name="PRESENCE_CLEAR_ON_PARTICIPANT_LEFT", environ_prefix=None
|
||||
)
|
||||
LOBBY_KEY_PREFIX = values.Value(
|
||||
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.25.0"
|
||||
version = "1.29.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+4
-4
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.25.0"
|
||||
version = "1.29.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -2252,11 +2252,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.5"
|
||||
version = "0.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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
+109
-110
@@ -1,41 +1,40 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.25.0",
|
||||
"version": "1.29.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.25.0",
|
||||
"version": "1.29.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-react": "2.9.23",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.12.0",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@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",
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"i18next-resources-to-backend": "1.2.3",
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.409.5",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-i18next": "17.0.10",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
@@ -743,34 +742,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.2.6.tgz",
|
||||
"integrity": "sha512-D0Z8peFkRGYACvmWp8Sl8YPGYLBEJbzqapugtsMEYSUUtpQDiZVDHjk9s7bqB4hSCRYCrKuW6V1V+mnHybVnPw==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.3.0.tgz",
|
||||
"integrity": "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
@@ -967,12 +966,12 @@
|
||||
"license": "GPL-3.0+"
|
||||
},
|
||||
"node_modules/@livekit/components-core": {
|
||||
"version": "0.12.13",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
|
||||
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
|
||||
"version": "0.12.14",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
|
||||
"integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "1.7.4",
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
"loglevel": "1.9.1",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
@@ -980,17 +979,17 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"livekit-client": "^2.17.2",
|
||||
"livekit-client": "^2.20.1",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/components-react": {
|
||||
"version": "2.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
|
||||
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==",
|
||||
"version": "2.9.23",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
|
||||
"integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/components-core": "0.12.13",
|
||||
"@livekit/components-core": "0.12.14",
|
||||
"clsx": "2.1.1",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.0.12",
|
||||
@@ -1000,8 +999,8 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
|
||||
"livekit-client": "^2.18.2",
|
||||
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0",
|
||||
"livekit-client": "^2.20.1",
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18",
|
||||
"tslib": "^2.6.2"
|
||||
@@ -1028,9 +1027,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.46.6",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||
"version": "1.50.4",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
|
||||
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
@@ -1049,18 +1048,12 @@
|
||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.35",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
||||
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
@@ -1649,21 +1642,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/preset-panda": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.3.tgz",
|
||||
"integrity": "sha512-IHeRQyIYeGKpQl78A/oI9fGg8MHQiOX/HttH+iQNaRU/MbGN6JsPe/N0ekQC6QY8iiKtLhqR+SRc455SN6JVWw==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.12.0.tgz",
|
||||
"integrity": "sha512-jhzhAmtGY34b1FlMJGEAh/IkfU4WyTNT2mpJfcBUPBi1rZLHSTcs0aqedRmMdwM4tTT43XFOXMiazbztKME60A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pandacss/types": "1.11.3"
|
||||
"@pandacss/types": "1.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.11.3.tgz",
|
||||
"integrity": "sha512-tUbxa3WzoCuulP6kcMn7f0uNgLBvu81JAg4sfoUpz6PZE36VHA3k9n6ROEwziTbr7vqgeCj5DYQCUldrVNbEGQ==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.12.0.tgz",
|
||||
"integrity": "sha512-PtlEIOBEV3RzWffeu1zH+/YFmP+fn24M63STGEbgTwjEBMhyo/FfT2sfZGB8oWAbWUBKBp69OMg25E+/rY3klQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -1726,19 +1719,29 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.39.5",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz",
|
||||
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==",
|
||||
"node_modules/@posthog/browser-common": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.3.1.tgz",
|
||||
"integrity": "sha512-1nhMVY1wnHADTg8tR9yvm+lPAz5ROxznfQlBtLzB2FFo4lp/LU8lk9KyFPsARDkBxCfYachsJfvRjocL1G/AJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.392.0"
|
||||
"@posthog/core": "^1.46.0",
|
||||
"@posthog/types": "^1.399.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.48.3",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
||||
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.405.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.392.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz",
|
||||
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==",
|
||||
"version": "1.405.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
||||
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-aria/overlays": {
|
||||
@@ -2421,9 +2424,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
||||
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
|
||||
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2442,12 +2445,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
||||
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
|
||||
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.1"
|
||||
"@tanstack/query-core": "5.101.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4400,11 +4403,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.39.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
|
||||
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
@@ -4716,15 +4722,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",
|
||||
@@ -6416,9 +6413,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6435,7 +6432,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -6988,9 +6985,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-resources-to-backend": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz",
|
||||
"integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.3.tgz",
|
||||
"integrity": "sha512-8Y/LLAm5fqZc2ckQxtTWbu75ndjNLzF7mcYl6rhc4g+WBopIsG3YhdYqF5qhJy64tlYhlU2KSSnEgW3gGHLqwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
@@ -8125,20 +8123,20 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.20.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||
"version": "2.21.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
|
||||
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.46.6",
|
||||
"@livekit/protocol": "1.50.4",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "9.0.5"
|
||||
"webrtc-adapter": "9.0.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1"
|
||||
@@ -9136,17 +9134,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.395.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"version": "1.409.5",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.409.5.tgz",
|
||||
"integrity": "sha512-s1iJz+vq0YAluUD4OwLjUFIJt/9pqiiB6MITvHWIS16a7pqTJqbSLXsd5/8kJcIgfrOSZHe+mdYAXLYcJCS4LQ==",
|
||||
"license": "(Apache-2.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.38.0",
|
||||
"@posthog/types": "^1.391.1",
|
||||
"core-js": "^3.38.1",
|
||||
"@posthog/browser-common": "^0.3.1",
|
||||
"@posthog/core": "^1.46.1",
|
||||
"@posthog/types": "^1.399.0",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.29.2",
|
||||
"preact": "^10.29.3",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.3.0"
|
||||
}
|
||||
@@ -9438,9 +9437,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -9450,7 +9449,7 @@
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
@@ -11661,9 +11660,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
||||
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
|
||||
+11
-12
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.25.0",
|
||||
"version": "1.29.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -15,34 +15,33 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-react": "2.9.23",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.12.0",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@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",
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"i18next-resources-to-backend": "1.2.3",
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.409.5",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-i18next": "17.0.10",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
|
||||
@@ -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,30 @@
|
||||
import type { CaptureResult } from 'posthog-js'
|
||||
|
||||
const IGNORED_EXCEPTION_PATTERNS = [
|
||||
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
|
||||
// livekit-client leaks the raw WebSocket error Event as an unhandled
|
||||
// rejection when the signal ws errors after connect (Firefox-heavy,
|
||||
// coincides with signal reconnects). Carries zero diagnostic content —
|
||||
// the close reason is already logged by the SDK.
|
||||
// See: https://github.com/livekit/client-sdk-js/issues/2062
|
||||
/^Event captured as exception with keys: isTrusted$/,
|
||||
]
|
||||
|
||||
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,151 @@
|
||||
import { getPosthog } from './utils'
|
||||
|
||||
export const captureEvent = (
|
||||
event: string,
|
||||
props?: Record<string, unknown>
|
||||
) => {
|
||||
void getPosthog()
|
||||
.then((ph) => {
|
||||
ph.capture(event, props)
|
||||
})
|
||||
.catch(() => {
|
||||
/* telemetry must never break the app */
|
||||
})
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[telemetry] ${event}`, props)
|
||||
}
|
||||
}
|
||||
|
||||
export type LogCode =
|
||||
// media
|
||||
| 'join_preview_failure'
|
||||
| 'room_media_failure'
|
||||
| 'livekit_room_error'
|
||||
| 'device_switch_failure'
|
||||
| 'permission_poll_failure'
|
||||
| 'media_devices_error_event'
|
||||
// non-media families
|
||||
| 'participant_mute_api_failure'
|
||||
| 'permissions_api_failure'
|
||||
| 'effects_processor_failure'
|
||||
| 'clipboard_failure'
|
||||
| 'fullscreen_failure'
|
||||
| 'publish_sources_failure'
|
||||
| 'disconnect_failure'
|
||||
| 'generic_failure'
|
||||
|
||||
export const reportError = (
|
||||
logCode: LogCode,
|
||||
error: unknown,
|
||||
extraInfo: Record<string, unknown> = {}
|
||||
): void => {
|
||||
const e = error instanceof Error ? error : new Error(String(error))
|
||||
void getPosthog()
|
||||
.then((ph) => {
|
||||
ph.captureException(e, {
|
||||
log_code: logCode,
|
||||
error_name: e.name,
|
||||
error_message: e.message,
|
||||
...extraInfo,
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[${logCode}]`, e, extraInfo)
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceSnapshot {
|
||||
cam_count: number
|
||||
mic_count: number
|
||||
out_count: number
|
||||
labels_visible: boolean
|
||||
saved_cam_present: boolean | null
|
||||
saved_mic_present: boolean | null
|
||||
saved_video_device_id_set: boolean
|
||||
saved_audio_device_id_set: boolean
|
||||
audio_enabled: boolean | null
|
||||
video_enabled: boolean | null
|
||||
cam_permission: PermissionState | 'unknown'
|
||||
mic_permission: PermissionState | 'unknown'
|
||||
}
|
||||
|
||||
/** Reads the persisted LiveKit user choices without importing the store. */
|
||||
const readPersistedChoices = (): {
|
||||
videoDeviceId?: string
|
||||
audioDeviceId?: string
|
||||
videoEnabled?: boolean
|
||||
audioEnabled?: boolean
|
||||
} => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('lk-user-choices') ?? '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const queryPermission = async (
|
||||
name: 'camera' | 'microphone'
|
||||
): Promise<PermissionState | 'unknown'> => {
|
||||
try {
|
||||
const status = await navigator.permissions.query({
|
||||
name: name as PermissionName,
|
||||
})
|
||||
return status.state
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceSnapshot = async (): Promise<DeviceSnapshot> => {
|
||||
const choices = readPersistedChoices()
|
||||
let devices: MediaDeviceInfo[] = []
|
||||
try {
|
||||
devices = await navigator.mediaDevices.enumerateDevices()
|
||||
} catch {
|
||||
/* snapshot stays partial */
|
||||
}
|
||||
const ofKind = (k: MediaDeviceKind) => devices.filter((d) => d.kind === k)
|
||||
const present = (k: MediaDeviceKind, id?: string) =>
|
||||
id ? ofKind(k).some((d) => d.deviceId === id) : null
|
||||
|
||||
const [cam_permission, mic_permission] = await Promise.all([
|
||||
queryPermission('camera'),
|
||||
queryPermission('microphone'),
|
||||
])
|
||||
|
||||
return {
|
||||
cam_count: ofKind('videoinput').length,
|
||||
mic_count: ofKind('audioinput').length,
|
||||
out_count: ofKind('audiooutput').length,
|
||||
labels_visible: devices.some((d) => !!d.label),
|
||||
saved_cam_present: present('videoinput', choices.videoDeviceId),
|
||||
saved_mic_present: present('audioinput', choices.audioDeviceId),
|
||||
saved_video_device_id_set: !!choices.videoDeviceId,
|
||||
saved_audio_device_id_set: !!choices.audioDeviceId,
|
||||
audio_enabled: choices.audioEnabled ?? null,
|
||||
video_enabled: choices.videoEnabled ?? null,
|
||||
cam_permission,
|
||||
mic_permission,
|
||||
}
|
||||
}
|
||||
|
||||
export const captureMediaEvent = async (
|
||||
event:
|
||||
| 'media-device-error'
|
||||
| 'media-acquisition'
|
||||
| 'media-device-topology'
|
||||
| 'media-device-success'
|
||||
| 'device-not-found'
|
||||
| 'device-in-use'
|
||||
| 'permissions-denied'
|
||||
| 'screen-share-permission-denied'
|
||||
| 'silent-mic-detected'
|
||||
| 'silent-mic-analyser-unavailable'
|
||||
| 'silent-mic-recovered'
|
||||
| 'visit-room'
|
||||
| 'connection-event',
|
||||
props: Record<string, unknown>
|
||||
) => {
|
||||
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
|
||||
let posthog: PostHog | null = null
|
||||
|
||||
export const getPosthog = async () => {
|
||||
if (!posthog) posthog = (await import('posthog-js')).default
|
||||
return posthog
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { queryClient } from '@/api/queryClient'
|
||||
import { updateUserPreferences } from './updateUserPreferences'
|
||||
import { 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])
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ChatProvider = () => {
|
||||
resetChatStore()
|
||||
}, [])
|
||||
|
||||
// Tigger the message notification (temporary)
|
||||
// Trigger the message notification (temporary)
|
||||
useEffect(() => {
|
||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { tokenize, createDefaultGrammar } from '@livekit/components-core'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const defaultGrammar = Object.freeze(createDefaultGrammar())
|
||||
|
||||
export function formatChatMessageLinks(message: string): ReactNode {
|
||||
const trimmedMessage = message.replace(/^[\r\n]+|[\r\n]+$/g, '')
|
||||
return tokenize(trimmedMessage, defaultGrammar).map((tok, i) => {
|
||||
if (typeof tok === `string`) {
|
||||
return tok
|
||||
} else {
|
||||
const content = tok.content.toString()
|
||||
const href =
|
||||
tok.type === `url`
|
||||
? /^http(s?):\/\//.test(content)
|
||||
? content
|
||||
: `https://${content}`
|
||||
: `mailto:${content}`
|
||||
return (
|
||||
<a
|
||||
className="lk-chat-link"
|
||||
key={i}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { css } from '@/styled-system/css'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const controlBarRegion = cva({
|
||||
mobile: {
|
||||
true: {
|
||||
justifyContent: 'center',
|
||||
width: '330px',
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { cva } from '@/styled-system/css'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { getTrackKey } from '@/features/layout/utils/trackSelection'
|
||||
|
||||
type OneToOneFocusLayoutProps = {
|
||||
mainTrack?: TrackReferenceOrPlaceholder
|
||||
thumbnailTrack?: TrackReferenceOrPlaceholder
|
||||
disableTileControls?: boolean
|
||||
/** Controls thumbnail dimensions – 'pip' for small PiP window, 'room' for the main viewport. */
|
||||
context?: 'pip' | 'room'
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus layout for 1-to-1 calls: one main tile filling the area (letterboxed)
|
||||
* with an optional thumbnail overlay at the bottom-right.
|
||||
*
|
||||
* Shared between PiP and the main room – pass `disableTileControls` in PiP
|
||||
* where hover controls should be hidden.
|
||||
*/
|
||||
export const OneToOneFocusLayout = memo(
|
||||
({
|
||||
mainTrack,
|
||||
thumbnailTrack,
|
||||
disableTileControls,
|
||||
context = 'room',
|
||||
}: OneToOneFocusLayoutProps) => {
|
||||
return (
|
||||
<FocusContainer>
|
||||
{mainTrack && (
|
||||
<MainSlot>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(mainTrack)}
|
||||
trackRef={mainTrack}
|
||||
disableTileControls={disableTileControls}
|
||||
/>
|
||||
</MainSlot>
|
||||
)}
|
||||
{thumbnailTrack && (
|
||||
<Thumbnail context={context}>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(thumbnailTrack)}
|
||||
trackRef={thumbnailTrack}
|
||||
disableTileControls={disableTileControls}
|
||||
/>
|
||||
</Thumbnail>
|
||||
)}
|
||||
</FocusContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
OneToOneFocusLayout.displayName = 'OneToOneFocusLayout'
|
||||
|
||||
const FocusContainer = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
})
|
||||
|
||||
const MainSlot = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Thumbnail = styled(
|
||||
'div',
|
||||
cva({
|
||||
base: {
|
||||
position: 'absolute',
|
||||
right: '1.25rem',
|
||||
bottom: '1.25rem',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'md',
|
||||
zIndex: 2,
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
context: {
|
||||
pip: {
|
||||
width: '42%',
|
||||
maxWidth: '220px',
|
||||
minWidth: '140px',
|
||||
},
|
||||
room: {
|
||||
width: '20%',
|
||||
maxWidth: '320px',
|
||||
minWidth: '180px',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
context: 'room',
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
import { Track } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
|
||||
|
||||
export const StageLayout = () => {
|
||||
const lastAutoFocusedScreenShareTrack =
|
||||
@@ -36,6 +37,29 @@ export const StageLayout = () => {
|
||||
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
||||
)
|
||||
|
||||
const cameraTracks = useMemo(
|
||||
() => tracks.filter((t) => t.source === Track.Source.Camera),
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const isOneToOne =
|
||||
!pinnedTrackRef &&
|
||||
screenShareTracks.length === 0 &&
|
||||
cameraTracks.length <= 2
|
||||
|
||||
const oneToOneMainTrack = useMemo(() => {
|
||||
if (!isOneToOne) return undefined
|
||||
const remote = cameraTracks.find((t) => !t.participant?.isLocal)
|
||||
const local = cameraTracks.find((t) => t.participant?.isLocal)
|
||||
return remote ?? local
|
||||
}, [isOneToOne, cameraTracks])
|
||||
|
||||
const oneToOneThumbnailTrack = useMemo(() => {
|
||||
if (!isOneToOne) return undefined
|
||||
const local = cameraTracks.find((t) => t.participant?.isLocal)
|
||||
return oneToOneMainTrack === local ? undefined : local
|
||||
}, [isOneToOne, cameraTracks, oneToOneMainTrack])
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||
useEffect(() => {
|
||||
@@ -87,7 +111,12 @@ export const StageLayout = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!pinnedTrackRef ? (
|
||||
{isOneToOne ? (
|
||||
<OneToOneFocusLayout
|
||||
mainTrack={oneToOneMainTrack}
|
||||
thumbnailTrack={oneToOneThumbnailTrack}
|
||||
/>
|
||||
) : !pinnedTrackRef ? (
|
||||
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
@@ -11,6 +11,7 @@ import { Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
import { setPinnedTrack } from '@/stores/layout'
|
||||
import { useParticipantTracks } from '@livekit/components-react'
|
||||
|
||||
const ClickableToast = styled(RACButton, {
|
||||
base: {
|
||||
@@ -30,13 +31,17 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
)
|
||||
const participant = props.toast.content.participant
|
||||
|
||||
if (!participant) return
|
||||
const [cameraTrack] = useParticipantTracks(
|
||||
[Source.Camera],
|
||||
participant?.identity
|
||||
)
|
||||
|
||||
const trackReference = {
|
||||
participant,
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
const trackReference = useMemo(
|
||||
() => cameraTrack ?? { participant, source: Source.Camera },
|
||||
[cameraTrack, participant]
|
||||
)
|
||||
|
||||
if (!participant) return
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+11
-26
@@ -1,11 +1,9 @@
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
import { openMuteDialog } from '@/stores/muteDialog'
|
||||
|
||||
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
@@ -15,31 +13,18 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
source: Track.Source.Microphone,
|
||||
})
|
||||
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => setIsAlertOpen(true)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => openMuteDialog(participant)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,11 @@ import {
|
||||
import Source = Track.Source
|
||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { useState } from 'react'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||
import { PinBadge } from './PinBadge'
|
||||
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
import { openMuteDialog } from '@/stores/muteDialog'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
@@ -34,7 +32,6 @@ type MicIndicatorProps = {
|
||||
|
||||
const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const { isMuted } = useTrackMutedIndicator({
|
||||
participant: participant,
|
||||
source: Source.Microphone,
|
||||
@@ -42,7 +39,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
|
||||
const canMute = useCanMute(participant)
|
||||
const isSpeaking = useIsSpeaking(participant)
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
const label = isLocal(participant)
|
||||
@@ -52,46 +48,34 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
square
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={label}
|
||||
aria-label={label}
|
||||
isDisabled={isMuted || !canMute}
|
||||
onPress={async () =>
|
||||
!isMuted && isLocal(participant)
|
||||
? await (participant as LocalParticipant)?.setMicrophoneEnabled(
|
||||
false
|
||||
)
|
||||
: setIsAlertOpen(true)
|
||||
}
|
||||
data-attr="participants-mute"
|
||||
>
|
||||
{isMuted ? (
|
||||
<RiMicOffFill color={'gray'} aria-hidden={true} />
|
||||
) : (
|
||||
<RiMicFill
|
||||
className={css({
|
||||
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
|
||||
animation: isSpeaking
|
||||
? 'pulse_background 800ms infinite'
|
||||
: undefined,
|
||||
})}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
square
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={label}
|
||||
aria-label={label}
|
||||
isDisabled={isMuted || !canMute}
|
||||
onPress={async () =>
|
||||
!isMuted && isLocal(participant)
|
||||
? await (participant as LocalParticipant)?.setMicrophoneEnabled(false)
|
||||
: openMuteDialog(participant)
|
||||
}
|
||||
data-attr="participants-mute"
|
||||
>
|
||||
{isMuted ? (
|
||||
<RiMicOffFill color={'gray'} aria-hidden={true} />
|
||||
) : (
|
||||
<RiMicFill
|
||||
className={css({
|
||||
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
|
||||
animation: isSpeaking
|
||||
? 'pulse_background 800ms infinite'
|
||||
: undefined,
|
||||
})}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +1,31 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby'
|
||||
import { useEnterRoom } from '../api/enterRoom'
|
||||
import {
|
||||
useListWaitingParticipants,
|
||||
type WaitingParticipant,
|
||||
} from '../../participants/api/listWaitingParticipants'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useWaitingParticipants = () => {
|
||||
const [listEnabled, setListEnabled] = useState(true)
|
||||
|
||||
const roomData = useRoomData()
|
||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||
|
||||
const room = useRoomContext()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const handleDataReceived = useCallback((payload: Uint8Array) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
if (notification?.type === NotificationType.ParticipantWaiting) {
|
||||
setListEnabled(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdminOrOwner) {
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
}, [isAdminOrOwner, room, handleDataReceived])
|
||||
const canManageLobby = useCanManageLobby()
|
||||
|
||||
const { data: waitingData, refetch: refetchWaiting } =
|
||||
useListWaitingParticipants(roomId, {
|
||||
retry: false,
|
||||
enabled: listEnabled && isAdminOrOwner,
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: true,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const waitingParticipants = useMemo(
|
||||
() => waitingData?.participants || [],
|
||||
[waitingData]
|
||||
() => (canManageLobby ? waitingData?.participants || [] : []),
|
||||
[waitingData, canManageLobby]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingParticipants.length) setListEnabled(false)
|
||||
}, [waitingParticipants])
|
||||
|
||||
const { mutateAsync: enterRoom } = useEnterRoom()
|
||||
|
||||
const handleParticipantEntry = async (
|
||||
@@ -73,8 +44,6 @@ export const useWaitingParticipants = () => {
|
||||
allowEntry: boolean
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setListEnabled(false)
|
||||
|
||||
await Promise.all(
|
||||
waitingParticipants.map((participant) =>
|
||||
enterRoom({
|
||||
@@ -87,8 +56,7 @@ export const useWaitingParticipants = () => {
|
||||
|
||||
await refetchWaiting()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setListEnabled(true)
|
||||
reportError('generic_failure', e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,86 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
type PipFocusLayoutProps = {
|
||||
mainTrack?: TrackReferenceOrPlaceholder
|
||||
thumbnailTrack?: TrackReferenceOrPlaceholder
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus layout used when 1-2 tracks are visible in the PiP window.
|
||||
*
|
||||
* The main tile is letterboxed (object-fit: contain) so the camera is
|
||||
* never stretched to a non-video aspect and leaves dark padding
|
||||
* above/below when the window shape doesn't match the source.
|
||||
* The thumbnail keeps the usual cover fill.
|
||||
*/
|
||||
export const PipFocusLayout = memo(
|
||||
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
|
||||
return (
|
||||
<FocusContainer>
|
||||
{mainTrack && (
|
||||
<MainSlot>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(mainTrack)}
|
||||
trackRef={mainTrack}
|
||||
disableTileControls
|
||||
/>
|
||||
</MainSlot>
|
||||
)}
|
||||
{thumbnailTrack && (
|
||||
<Thumbnail>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(thumbnailTrack)}
|
||||
trackRef={thumbnailTrack}
|
||||
disableTileControls
|
||||
/>
|
||||
</Thumbnail>
|
||||
)}
|
||||
</FocusContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
PipFocusLayout.displayName = 'PipFocusLayout'
|
||||
|
||||
const FocusContainer = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
})
|
||||
|
||||
const MainSlot = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Thumbnail = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
right: '1.25rem',
|
||||
bottom: '1.25rem',
|
||||
width: '42%',
|
||||
maxWidth: '220px',
|
||||
minWidth: '140px',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'md',
|
||||
zIndex: 2,
|
||||
},
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
import { getTrackKey } from '@/features/layout/utils/trackSelection'
|
||||
|
||||
type PipGridLayoutProps = {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
import { getTrackKey } from '@/features/layout/utils/trackSelection'
|
||||
|
||||
type PipScreenShareLayoutProps = {
|
||||
screenShareTrack: TrackReferenceOrPlaceholder
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useMemo } from 'react'
|
||||
import { usePagination, useTracks } from '@livekit/components-react'
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { PipFocusLayout } from './PipFocusLayout'
|
||||
import { OneToOneFocusLayout } from '@/features/layout/components/OneToOneFocusLayout'
|
||||
import { PipGridLayout } from './PipGridLayout'
|
||||
import { PipPagination } from './PipPagination'
|
||||
import { PipScreenShareLayout } from './PipScreenShareLayout'
|
||||
@@ -59,9 +59,11 @@ export const PipStage = () => {
|
||||
if (cameraTracks.length <= 1) {
|
||||
return (
|
||||
<StageFrame>
|
||||
<PipFocusLayout
|
||||
<OneToOneFocusLayout
|
||||
mainTrack={screenShareTrack}
|
||||
thumbnailTrack={cameraTracks[0]}
|
||||
disableTileControls
|
||||
context="pip"
|
||||
/>
|
||||
</StageFrame>
|
||||
)
|
||||
@@ -99,7 +101,12 @@ export const PipStage = () => {
|
||||
|
||||
return (
|
||||
<StageFrame>
|
||||
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
|
||||
<OneToOneFocusLayout
|
||||
mainTrack={mainTrack}
|
||||
thumbnailTrack={thumbnailTrack}
|
||||
disableTileControls
|
||||
context="pip"
|
||||
/>
|
||||
</StageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEmotionLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
@@ -7,6 +6,8 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
|
||||
import { REACTIONS_TOOLBAR_ID } from '../constants'
|
||||
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
const focusReactionsToolbar = () => {
|
||||
document
|
||||
@@ -17,35 +18,44 @@ const focusReactionsToolbar = () => {
|
||||
|
||||
export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
|
||||
|
||||
export const ReactionsToggle = () => {
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
export const reactionShortcutHandler = () => {
|
||||
if (layoutStore.showReactionsToolbar) {
|
||||
focusReactionsToolbar()
|
||||
} else {
|
||||
layoutStore.showReactionsToolbar = true
|
||||
}
|
||||
}
|
||||
|
||||
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
|
||||
|
||||
export const ReactionsToggle = ({
|
||||
variant = 'primaryDark',
|
||||
onPress,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
|
||||
const { isOpen, toggle } = useReactionsToolbar()
|
||||
|
||||
const handleShortcut = useCallback(() => {
|
||||
if (layoutStore.showReactionsToolbar) {
|
||||
focusReactionsToolbar()
|
||||
} else {
|
||||
layoutStore.showReactionsToolbar = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: handleShortcut,
|
||||
handler: reactionShortcutHandler,
|
||||
})
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
{...props}
|
||||
id={REACTIONS_TOGGLE_ID}
|
||||
data-attr="reactions-toggle"
|
||||
square
|
||||
variant="primaryDark"
|
||||
variant={variant}
|
||||
aria-label={t('button')}
|
||||
aria-expanded={isOpen}
|
||||
tooltip={t('button')}
|
||||
isSelected={isOpen}
|
||||
onChange={toggle}
|
||||
onPress={onPress}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
</ToggleButton>
|
||||
|
||||
@@ -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,17 +1,20 @@
|
||||
import { useStartRecording, useStopRecording } from '@/features/recording'
|
||||
import { recordingStore } from '@/stores/recording'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useMutateRecording = () => {
|
||||
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => {
|
||||
recordingStore.isErrorDialogOpen = 'start'
|
||||
captureEvent('error-starting-recording')
|
||||
},
|
||||
})
|
||||
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => {
|
||||
recordingStore.isErrorDialogOpen = 'stop'
|
||||
captureEvent('error-stopping-recording')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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,7 @@ import { fetchApi } from '@/api/fetchApi'
|
||||
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const apiRoomData = useRoomData()
|
||||
@@ -31,7 +32,10 @@ export const useMuteParticipant = () => {
|
||||
|
||||
// Guard against undefined token for non-admin users
|
||||
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
|
||||
console.error('Cannot mute participant: missing auth token')
|
||||
reportError(
|
||||
'participant_mute_api_failure',
|
||||
new Error('Cannot mute participant: missing auth token')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -53,8 +57,11 @@ export const useMuteParticipant = () => {
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
reportError(
|
||||
'participant_mute_api_failure',
|
||||
new Error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -65,8 +72,11 @@ export const useMuteParticipant = () => {
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||
reportError(
|
||||
'participant_mute_api_failure',
|
||||
new Error(
|
||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useMuteParticipant } from './muteParticipant'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useMuteParticipants = () => {
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
@@ -11,7 +12,9 @@ export const useMuteParticipants = () => {
|
||||
)
|
||||
return Promise.all(promises)
|
||||
} catch (error) {
|
||||
console.error('An error occurred while muting participants :', error)
|
||||
reportError('participant_mute_api_failure', error, {
|
||||
context: 'An error occurred while muting participants :',
|
||||
})
|
||||
throw new Error('An error occurred while muting participants.', {
|
||||
cause: error,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
usePersistentUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
DisconnectReason,
|
||||
MediaDeviceFailure,
|
||||
Room,
|
||||
type RoomOptions,
|
||||
VideoPresets,
|
||||
} from 'livekit-client'
|
||||
import { getMediaDeviceFailure } from '@/features/rooms/livekit/utils/mediaPermissions'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
@@ -25,18 +28,22 @@ 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 {
|
||||
captureEvent,
|
||||
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'
|
||||
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -47,7 +54,6 @@ export const Conference = ({
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
}) => {
|
||||
const posthog = usePostHog()
|
||||
const { data: apiConfig } = useConfig()
|
||||
|
||||
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||
@@ -57,8 +63,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)
|
||||
@@ -110,6 +116,7 @@ export const Conference = ({
|
||||
},
|
||||
audioCaptureDefaults: {
|
||||
deviceId: userConfig.audioDeviceId ?? undefined,
|
||||
...VOICE_AUDIO_CONSTRAINTS,
|
||||
},
|
||||
audioOutput: {
|
||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||
@@ -170,14 +177,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 +234,21 @@ export const Conference = ({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
onError={(e) => {
|
||||
posthog.captureException(e)
|
||||
const failure = getMediaDeviceFailure(e)
|
||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||
|
||||
// connect() was aborted by a disconnect() before the join completed
|
||||
if (
|
||||
e instanceof ConnectionError &&
|
||||
e.reason === ConnectionErrorReason.Cancelled
|
||||
) {
|
||||
void captureEvent('connection-cancelled')
|
||||
return
|
||||
}
|
||||
|
||||
reportError('livekit_room_error', e, {
|
||||
path: 'connect_publish',
|
||||
})
|
||||
}}
|
||||
onConnected={async () => {
|
||||
if (!apiConfig) return
|
||||
@@ -253,23 +266,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 +293,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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useListWaitingParticipants } from '@/features/participants/api/listWaitingParticipants'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications'
|
||||
import { usePrevious } from '@/hooks/usePrevious'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
export const LAZY_POLL_INTERVAL_MS = 10_000
|
||||
|
||||
export const LobbyProvider = () => {
|
||||
const room = useRoomContext()
|
||||
|
||||
const canManageLobby = useCanManageLobby()
|
||||
const roomData = useRoomData()
|
||||
const { isParticipantsOpen } = useSidePanel()
|
||||
const isConnected = useConnectionState(room) === ConnectionState.Connected
|
||||
|
||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||
|
||||
const { error: waitingError, refetch: refetchWaiting } =
|
||||
useListWaitingParticipants(roomId, {
|
||||
retry: false,
|
||||
enabled: canManageLobby && isConnected && !!roomId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: (query) => {
|
||||
if (!query.state.data?.participants?.length) return false
|
||||
if (isParticipantsOpen) return POLL_INTERVAL_MS
|
||||
return LAZY_POLL_INTERVAL_MS
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
|
||||
// Triggers: each one-shot, idempotent, deduped by React Query if
|
||||
// concurrent. The interval takes over whenever a fetch finds waiters.
|
||||
const fetchIfManager = useCallback(() => {
|
||||
if (canManageLobby) refetchWaiting()
|
||||
}, [canManageLobby, refetchWaiting])
|
||||
|
||||
// 1. Connection established (join or reconnect)
|
||||
useEffect(() => {
|
||||
room.on(RoomEvent.Connected, fetchIfManager)
|
||||
room.on(RoomEvent.Reconnected, fetchIfManager)
|
||||
return () => {
|
||||
room.off(RoomEvent.Connected, fetchIfManager)
|
||||
room.off(RoomEvent.Reconnected, fetchIfManager)
|
||||
}
|
||||
}, [room, fetchIfManager])
|
||||
|
||||
// 2. Someone started waiting (LiveKit broadcast).
|
||||
const handleDataReceived = useCallback(
|
||||
(payload: Uint8Array) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
if (notification?.type === NotificationType.ParticipantWaiting) {
|
||||
fetchIfManager()
|
||||
}
|
||||
},
|
||||
[fetchIfManager]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (canManageLobby) {
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
}, [canManageLobby, room, handleDataReceived])
|
||||
|
||||
// 3. Rights regained.
|
||||
const prevCanManageLobby = usePrevious(canManageLobby)
|
||||
useEffect(() => {
|
||||
if (!prevCanManageLobby && canManageLobby && isConnected) {
|
||||
fetchIfManager()
|
||||
}
|
||||
}, [
|
||||
prevCanManageLobby,
|
||||
canManageLobby,
|
||||
isParticipantsOpen,
|
||||
fetchIfManager,
|
||||
isConnected,
|
||||
])
|
||||
|
||||
const clearWaitingList = useCallback(() => {
|
||||
const queryKey = [keys.waitingParticipants, roomId]
|
||||
queryClient.cancelQueries({ queryKey })
|
||||
queryClient.setQueryData(queryKey, { participants: [] })
|
||||
}, [roomId])
|
||||
|
||||
// Rights lost mid-meeting (covers trusted -> restricted/public).
|
||||
useEffect(() => {
|
||||
if (prevCanManageLobby && !canManageLobby) clearWaitingList()
|
||||
}, [prevCanManageLobby, canManageLobby, clearWaitingList])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
waitingError instanceof ApiError &&
|
||||
[401, 403].includes(waitingError.statusCode)
|
||||
) {
|
||||
clearWaitingList()
|
||||
}
|
||||
}, [waitingError, clearWaitingList])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -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: {
|
||||
@@ -17,7 +15,8 @@ const Card = styled('div', {
|
||||
marginTop: '1.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
boxShadow: '',
|
||||
minWidth: '380px',
|
||||
width: '100%',
|
||||
maxWidth: '380px',
|
||||
minHeight: '196px',
|
||||
},
|
||||
})
|
||||
@@ -39,8 +38,11 @@ const ratingButtonRecipe = cva({
|
||||
color: 'initial',
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
padding: '0.5rem 0.85rem',
|
||||
padding: { base: '0.5rem 0.25rem', xsm: '0.5rem 0.85rem' },
|
||||
flexGrow: '1',
|
||||
flexBasis: 0,
|
||||
minWidth: 0,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
variants: {
|
||||
@@ -72,11 +74,9 @@ const labelRecipe = cva({
|
||||
})
|
||||
|
||||
const OpenFeedback = ({
|
||||
posthog,
|
||||
onNext,
|
||||
metadata,
|
||||
}: {
|
||||
posthog: PostHog
|
||||
onNext: () => void
|
||||
metadata?: Record<string, unknown>
|
||||
}) => {
|
||||
@@ -90,7 +90,7 @@ const OpenFeedback = ({
|
||||
|
||||
const onSubmit = () => {
|
||||
try {
|
||||
posthog.capture('open-feedback', {
|
||||
captureEvent('open-feedback', {
|
||||
feedback,
|
||||
...metadata,
|
||||
})
|
||||
@@ -103,7 +103,9 @@ const OpenFeedback = ({
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<H lvl={3}>{t('question')}</H>
|
||||
<H lvl={3} centered>
|
||||
{t('question')}
|
||||
</H>
|
||||
<TextArea
|
||||
id="feedbackInput"
|
||||
name="feedback"
|
||||
@@ -141,12 +143,10 @@ const OpenFeedback = ({
|
||||
}
|
||||
|
||||
const RateQuality = ({
|
||||
posthog,
|
||||
onNext,
|
||||
metadata,
|
||||
maxRating = 5,
|
||||
}: {
|
||||
posthog: PostHog
|
||||
onNext: () => void
|
||||
metadata?: Record<string, unknown>
|
||||
maxRating?: number
|
||||
@@ -160,7 +160,7 @@ const RateQuality = ({
|
||||
|
||||
const onSubmit = () => {
|
||||
try {
|
||||
posthog.capture('quality-rating', {
|
||||
captureEvent('quality-rating', {
|
||||
rating: selectedRating,
|
||||
...metadata,
|
||||
})
|
||||
@@ -174,7 +174,9 @@ const RateQuality = ({
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<H lvl={3}>{t('question')}</H>
|
||||
<H lvl={3} centered>
|
||||
{t('question')}
|
||||
</H>
|
||||
<Bar>
|
||||
{[...Array(maxRating)].map((_, index) => (
|
||||
<RACButton
|
||||
@@ -234,7 +236,9 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
||||
}}
|
||||
>
|
||||
<VStack gap={0}>
|
||||
<H lvl={3}>{t('heading')}</H>
|
||||
<H lvl={3} centered>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Text as="p" variant="paragraph" centered>
|
||||
{t('body')}
|
||||
</Text>
|
||||
@@ -243,73 +247,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 +257,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 +272,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} />
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react'
|
||||
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
|
||||
|
||||
export function useWatchDeviceAvailability() {
|
||||
useEffect(() => {
|
||||
if (!navigator.mediaDevices) return
|
||||
syncDeviceAvailability()
|
||||
navigator.mediaDevices.addEventListener(
|
||||
'devicechange',
|
||||
syncDeviceAvailability
|
||||
)
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
'devicechange',
|
||||
syncDeviceAvailability
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
clearDeviceInUseForOtherDevice,
|
||||
deviceAvailabilityStore,
|
||||
} from '@/stores/deviceAvailability'
|
||||
import { userChoicesStore } from '@/stores/userChoices'
|
||||
import { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
|
||||
import type { PermissionKind } from '@/stores/permissions'
|
||||
|
||||
const RETRY_INTERVAL_MS = 30_000
|
||||
|
||||
/**
|
||||
* There is no browser event for "another app released the device", so
|
||||
* while a device is flagged in use, re-probe it periodically. The probe
|
||||
* targets the currently selected device (the flag is about what the app
|
||||
* would acquire, not about any device of the kind). Only clears the flag
|
||||
* (the toggle becomes usable again); it never re-enables the device on
|
||||
* the user's behalf.
|
||||
*/
|
||||
export function useWatchDeviceReleased() {
|
||||
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
||||
const { audioDeviceId, videoDeviceId } = useSnapshot(userChoicesStore)
|
||||
useWatchKind('camera', cameraInUse, videoDeviceId)
|
||||
useWatchKind('microphone', microphoneInUse, audioDeviceId)
|
||||
}
|
||||
|
||||
function useWatchKind(
|
||||
kind: PermissionKind,
|
||||
inUse: boolean,
|
||||
selectedDeviceId?: string
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (inUse) clearDeviceInUseForOtherDevice(kind, selectedDeviceId)
|
||||
}, [kind, inUse, selectedDeviceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!inUse) return
|
||||
let stopped = false
|
||||
let timer: number | undefined
|
||||
const tick = async () => {
|
||||
await probeDeviceReleased(kind, selectedDeviceId || undefined)
|
||||
if (!stopped) {
|
||||
timer = window.setTimeout(tick, RETRY_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
timer = window.setTimeout(tick, RETRY_INTERVAL_MS)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [kind, inUse, selectedDeviceId])
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Stack } from '@/styled-system/jsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
@@ -134,7 +134,11 @@ export const IsIdleDisconnectModal = () => {
|
||||
</H>
|
||||
<Description />
|
||||
<Settings />
|
||||
<HStack marginTop="2rem">
|
||||
<Stack
|
||||
direction={{ base: 'column', xsm: 'row' }}
|
||||
align={{ base: 'stretch', xsm: 'center' }}
|
||||
marginTop="2rem"
|
||||
>
|
||||
<Button
|
||||
onPress={() => {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
@@ -148,7 +152,7 @@ export const IsIdleDisconnectModal = () => {
|
||||
<Button onPress={close} size="sm" variant="primary">
|
||||
{t('stayButton')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { closeMuteDialog, muteDialogStore } from '@/stores/muteDialog'
|
||||
import { MuteAlertDialog } from './MuteAlertDialog'
|
||||
|
||||
export const MuteAlertDialogProvider = () => {
|
||||
const { participant } = useSnapshot(muteDialogStore)
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
|
||||
const lastNameRef = useRef('')
|
||||
if (participant) {
|
||||
lastNameRef.current = participant.name || participant.identity
|
||||
}
|
||||
|
||||
return (
|
||||
<MuteAlertDialog
|
||||
isOpen={!!participant}
|
||||
name={lastNameRef.current}
|
||||
onClose={closeMuteDialog}
|
||||
onSubmit={() => {
|
||||
const target = muteDialogStore.participant
|
||||
if (!target) return
|
||||
muteParticipant(target).then(closeMuteDialog)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { A, Button, Dialog, P } from '@/primitives'
|
||||
import { 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
|
||||
}
|
||||
+5
-7
@@ -1,5 +1,4 @@
|
||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
FilesetResolver,
|
||||
ImageSegmenter,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
type ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
} from '.'
|
||||
import { captureEvent } from '@/features/analytics/telemetry.ts'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
const PROCESSING_HEIGHT = 144
|
||||
@@ -44,7 +44,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
videoElement?: HTMLVideoElement
|
||||
videoElementLoaded?: boolean
|
||||
|
||||
// Canvas containg the video processing result, of which we extract as stream.
|
||||
// Canvas containing the video processing result, of which we extract as stream.
|
||||
outputCanvas?: HTMLCanvasElement
|
||||
outputCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
@@ -55,7 +55,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
segmentationMaskCanvas?: HTMLCanvasElement
|
||||
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
// Mask containg the inference result.
|
||||
// Mask containing the inference result.
|
||||
segmentationMask?: ImageData
|
||||
|
||||
// The resized image of the video source.
|
||||
@@ -100,14 +100,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
await this.initSegmenter()
|
||||
this._initWorker()
|
||||
|
||||
posthog.capture('firefox-blurring-init')
|
||||
captureEvent('firefox-blurring-init', {})
|
||||
}
|
||||
|
||||
_initVirtualBackgroundImage() {
|
||||
if (this.options.type !== 'virtual') {
|
||||
throw new Error(
|
||||
'Virtual background is only supported for virtual background'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const needsUpdate =
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
FilesetResolver,
|
||||
FaceLandmarker,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
} from '.'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
const PROCESSING_WIDTH = 256 * 3
|
||||
const PROCESSING_HEIGHT = 144 * 3
|
||||
@@ -101,7 +101,7 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
||||
await this.initFaceLandmarker()
|
||||
this._initWorker()
|
||||
|
||||
posthog.capture('face-landmarks-init')
|
||||
captureEvent('face-landmarks-init', {})
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ProcessorWrapper } from '@livekit/track-processors'
|
||||
import {
|
||||
ProcessorWrapper,
|
||||
supportsBackgroundProcessors,
|
||||
} from '@livekit/track-processors'
|
||||
import type { Track, TrackProcessor } from 'livekit-client'
|
||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||
@@ -10,7 +13,7 @@ export const SELFIE_SEGMENTER_MODEL_PATH =
|
||||
export const FACE_LANDMARKS_MODEL_PATH =
|
||||
'/assets/mediapipe/models/face_landmarker.task'
|
||||
|
||||
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
|
||||
export const MEDIAPIPE_PATH_WASM = `/assets/mediapipe/wasm/${__MEDIAPIPE_VERSION__}`
|
||||
|
||||
export enum ProcessorType {
|
||||
BLUR = 'blur',
|
||||
@@ -34,7 +37,9 @@ export class BackgroundProcessorFactory {
|
||||
}
|
||||
|
||||
static isSupported() {
|
||||
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
|
||||
return (
|
||||
supportsBackgroundProcessors() || BackgroundCustomProcessor.isSupported
|
||||
)
|
||||
}
|
||||
|
||||
static getProcessor(
|
||||
@@ -45,7 +50,7 @@ export class BackgroundProcessorFactory {
|
||||
|
||||
if (!isBlur && !isVirtual) return undefined
|
||||
|
||||
if (ProcessorWrapper.isSupported) {
|
||||
if (supportsBackgroundProcessors()) {
|
||||
return new UnifiedBackgroundTrackProcessor(config)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RiCameraSwitchLine } from '@remixicon/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ButtonProps } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
enum FacingMode {
|
||||
USER = 'user',
|
||||
@@ -103,7 +104,11 @@ export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
|
||||
setActiveMediaDevice(device.deviceId)
|
||||
setFacingMode(target)
|
||||
} else {
|
||||
console.error('Cannot get user device with facingMode ' + target)
|
||||
reportError(
|
||||
'device_switch_failure',
|
||||
new Error('Cannot get user device with facingMode ' + target),
|
||||
{ path: 'switch_device', kind: 'videoinput', facing_mode: target }
|
||||
)
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
||||
+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>
|
||||
)
|
||||
}
|
||||
+19
-4
@@ -4,8 +4,19 @@ import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const PermissionNeededButton = () => {
|
||||
type PermissionNeededButtonProps = {
|
||||
tooltip?: string | null
|
||||
onPress?: (() => void) | null
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export const PermissionNeededButton = ({
|
||||
tooltip,
|
||||
onPress,
|
||||
ariaLabel,
|
||||
}: PermissionNeededButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
||||
const label = tooltip === undefined ? t('tooltip') : tooltip
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -17,9 +28,13 @@ export const PermissionNeededButton = () => {
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
aria-label={t('ariaLabel')}
|
||||
tooltip={t('tooltip')}
|
||||
onPress={() => openPermissionsDialog()}
|
||||
aria-label={ariaLabel ?? label ?? t('ariaLabel')}
|
||||
tooltip={label ?? undefined}
|
||||
onPress={
|
||||
onPress === undefined
|
||||
? () => openPermissionsDialog()
|
||||
: (onPress ?? undefined)
|
||||
}
|
||||
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}
|
||||
/>
|
||||
|
||||
+100
-13
@@ -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,18 @@ 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 { useDeviceInUse } from '../../../hooks/useDeviceInUse'
|
||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||
import { requestDevicePermission } from '../../../utils/mediaPermissions'
|
||||
import { userChoicesStore } from '@/stores/userChoices'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
import type {
|
||||
@@ -90,9 +98,58 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceMissing = useDeviceMissing(kind)
|
||||
const deviceInUse = useDeviceInUse(kind)
|
||||
const explainDeviceInUse = deviceInUse && context === 'room'
|
||||
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||
const silentMicWarning =
|
||||
kind === 'audioinput' &&
|
||||
silentMicStatus === 'silent' &&
|
||||
!cannotUseDevice &&
|
||||
!deviceMissing &&
|
||||
!deviceInUse
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const isRequestingPermission = useRef(false)
|
||||
const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
|
||||
|
||||
const mediaPath = context === 'join' ? 'join_preview' : 'room'
|
||||
|
||||
const onPress = async () => {
|
||||
if (!enabled && deviceMissing) {
|
||||
setAlertError(MediaDeviceFailure.NotFound)
|
||||
return
|
||||
}
|
||||
if (!cannotUseDevice && !deviceInUse) {
|
||||
toggle()
|
||||
return
|
||||
}
|
||||
if (isRequestingPermission.current) return
|
||||
isRequestingPermission.current = true
|
||||
try {
|
||||
const selectedDeviceId = cannotUseDevice
|
||||
? undefined
|
||||
: kind === 'videoinput'
|
||||
? userChoicesStore.videoDeviceId
|
||||
: userChoicesStore.audioDeviceId
|
||||
const acquired = await requestDevicePermission(
|
||||
kind,
|
||||
mediaPath,
|
||||
selectedDeviceId || undefined
|
||||
)
|
||||
if (acquired) {
|
||||
toggle()
|
||||
} else if (cannotUseDevice) {
|
||||
openPermissionsDialog(kind)
|
||||
} else if (explainDeviceInUse) {
|
||||
setAlertError(MediaDeviceFailure.DeviceInUse)
|
||||
}
|
||||
} finally {
|
||||
isRequestingPermission.current = false
|
||||
}
|
||||
}
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: deviceShortcut?.id,
|
||||
handler: async () => {
|
||||
@@ -137,9 +194,43 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
return <ActiveSpeakerWrapper />
|
||||
}
|
||||
|
||||
const getToggleTooltip = () => {
|
||||
if (deviceMissing) return t(`deviceNotFound.${kind}`)
|
||||
if (explainDeviceInUse) return t(`deviceInUse.${kind}`)
|
||||
if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
return toggleLabel
|
||||
}
|
||||
const toggleTooltip = getToggleTooltip()
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{cannotUseDevice && <PermissionNeededButton />}
|
||||
{(cannotUseDevice || deviceMissing) && (
|
||||
<PermissionNeededButton
|
||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||
onPress={
|
||||
deviceMissing
|
||||
? () => setAlertError(MediaDeviceFailure.NotFound)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{deviceInUse && (
|
||||
<PermissionNeededButton
|
||||
tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : null}
|
||||
ariaLabel={t(`deviceInUse.${kind}`)}
|
||||
onPress={
|
||||
explainDeviceInUse
|
||||
? () => setAlertError(MediaDeviceFailure.DeviceInUse)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{silentMicWarning && (
|
||||
<PermissionNeededButton
|
||||
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
|
||||
onPress={openSilentMicDialog}
|
||||
/>
|
||||
)}
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
isDisabled={isDisabled}
|
||||
@@ -147,23 +238,19 @@ 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
|
||||
}
|
||||
tooltip={toggleTooltip}
|
||||
{...computedToggleButtonProps}
|
||||
{...overrideToggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
<MediaDeviceErrorAlert
|
||||
error={alertError}
|
||||
kind={kind}
|
||||
onClose={() => setAlertError(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,10 +10,18 @@ import {
|
||||
showLowerHandToast,
|
||||
} from '@/features/notifications/utils'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
const SPEAKING_DETECTION_DELAY = 3000
|
||||
|
||||
export const HandToggle = () => {
|
||||
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
|
||||
|
||||
export const HandToggle = ({
|
||||
variant = 'primaryDark',
|
||||
onPress,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
||||
|
||||
const room = useRoomContext()
|
||||
@@ -74,12 +82,16 @@ export const HandToggle = () => {
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
{...props}
|
||||
square
|
||||
variant="primaryDark"
|
||||
variant={variant}
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={handleToggle}
|
||||
onPress={(e) => {
|
||||
handleToggle()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
>
|
||||
<RiHand />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/primitives'
|
||||
import { RiPhoneFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ConnectionState } from 'livekit-client'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const LeaveButton = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
||||
@@ -15,11 +16,11 @@ export const LeaveButton = () => {
|
||||
tooltip={t('leave')}
|
||||
aria-label={t('leave')}
|
||||
onPress={() => {
|
||||
room
|
||||
.disconnect(true)
|
||||
.catch((e) =>
|
||||
console.error('An error occurred while disconnecting:', e)
|
||||
)
|
||||
room.disconnect(true).catch((e) =>
|
||||
reportError('disconnect_failure', e, {
|
||||
context: 'An error occurred while disconnecting:',
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="controls-leave"
|
||||
>
|
||||
|
||||
@@ -33,11 +33,12 @@ import { useConfig } from '@/api/useConfig.ts'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
import { Spinner } from '@/primitives/Spinner.tsx'
|
||||
import { userChoicesStore, saveProcessorConfig } from '@/stores/userChoices'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
LIGHT = 5,
|
||||
NORMAL = 10,
|
||||
LIGHT = 10,
|
||||
NORMAL = 20,
|
||||
}
|
||||
|
||||
const isSupported = BackgroundProcessorFactory.isSupported()
|
||||
@@ -238,7 +239,9 @@ export const EffectsConfiguration = ({
|
||||
|
||||
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
reportError('effects_processor_failure', error, {
|
||||
context: 'Error applying effect:',
|
||||
})
|
||||
} finally {
|
||||
// Without setTimeout the DOM is not refreshing when updating the options.
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RiGlassesLine, RiGoblet2Fill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
|
||||
import type { LocalVideoTrack } from 'livekit-client'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export type FunnyEffectsProps = {
|
||||
videoTrack: LocalVideoTrack
|
||||
@@ -55,7 +56,9 @@ export const FunnyEffects = ({
|
||||
await videoTrack.setProcessor(newProcessor)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('could not update processor', e)
|
||||
reportError('effects_processor_failure', e, {
|
||||
context: 'could not update processor',
|
||||
})
|
||||
} finally {
|
||||
onPending(false)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user