mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 681a8b1de9 | |||
| 70114d325b | |||
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 | |||
| 13c9d3635c | |||
| eced9891c6 | |||
| ef05c0ab19 | |||
| 932332c858 | |||
| 7343560a9b | |||
| fca7c3b8b8 | |||
| 29672cda6b | |||
| c3c1e918b0 | |||
| d1dde71bea | |||
| 64b4200fc3 | |||
| 4b393e28ca | |||
| 8dc1bacc38 | |||
| 97a9735c0b | |||
| d2e13cc826 | |||
| 183b0fc697 | |||
| b7be0eabb0 | |||
| f21c4c91ed | |||
| 5f52fa8d8d | |||
| 9420fed563 | |||
| 4598aafa23 | |||
| 94abdfc0f9 | |||
| 56a8f72e39 | |||
| 86fcc2c7fe | |||
| 0a0306b0a2 | |||
| c00fcc78e1 | |||
| 39a59d1b35 | |||
| f4493d412f | |||
| e1457604ee | |||
| bf6351e838 | |||
| aba5fca655 | |||
| acb583b14f | |||
| fb3a54a224 | |||
| 8cd6496b5b | |||
| 63a7de402b | |||
| f842cc340e | |||
| 8c235571eb | |||
| d6f39e602d | |||
| 0c0b2f2616 |
@@ -11,6 +11,8 @@ and this project adheres to
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(summary) report exception type in failure analytics
|
- ✨(summary) report exception type in failure analytics
|
||||||
|
- ✨(frontend) add configurable documentation menu item
|
||||||
|
- ✨(frontend) add connection test feature
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ and this project adheres to
|
|||||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||||
|
- ⚡️(frontend) limit unnecessary re-renders #1510
|
||||||
|
|
||||||
## [1.24.0] - 2026-07-21
|
## [1.24.0] - 2026-07-21
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ These are the environmental options available on meet backend.
|
|||||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||||
|
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Connection test API endpoint."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework.decorators import api_view, throttle_classes
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from core.api.throttling import (
|
||||||
|
ConnectionTestAnonRateThrottle,
|
||||||
|
ConnectionTestUserRateThrottle,
|
||||||
|
)
|
||||||
|
from core.utils import generate_token
|
||||||
|
|
||||||
|
CONNECTION_TEST_USERNAME = "Test connexion"
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
@throttle_classes([ConnectionTestUserRateThrottle, ConnectionTestAnonRateThrottle])
|
||||||
|
def get_connection_test_config(request):
|
||||||
|
"""Return a short-lived LiveKit token for an ephemeral connection test room."""
|
||||||
|
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid4()}"
|
||||||
|
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"livekit": {
|
||||||
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
|
"room": room,
|
||||||
|
"token": generate_token(
|
||||||
|
room=room,
|
||||||
|
user=request.user,
|
||||||
|
username=CONNECTION_TEST_USERNAME,
|
||||||
|
is_admin_or_owner=False,
|
||||||
|
ttl=timedelta(seconds=expires_in),
|
||||||
|
),
|
||||||
|
"expires_in": expires_in,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -73,3 +73,15 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
"""Throttle Anonymous user requesting room generation callback"""
|
"""Throttle Anonymous user requesting room generation callback"""
|
||||||
|
|
||||||
scope = "creation_callback"
|
scope = "creation_callback"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
|
||||||
|
"""Throttle authenticated users requesting connection test tokens."""
|
||||||
|
|
||||||
|
scope = "connection_test"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
|
"""Throttle anonymous users requesting connection test tokens."""
|
||||||
|
|
||||||
|
scope = "connection_test"
|
||||||
|
|||||||
@@ -218,9 +218,21 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_connection_test_room(room_name: str) -> bool:
|
||||||
|
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
||||||
|
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
|
||||||
|
|
||||||
def _handle_room_started(self, data):
|
def _handle_room_started(self, data):
|
||||||
"""Handle 'room_started' event."""
|
"""Handle 'room_started' event."""
|
||||||
|
|
||||||
|
if self._is_connection_test_room(data.room.name):
|
||||||
|
logger.info(
|
||||||
|
"Ignoring room_started event for connection test room '%s'.",
|
||||||
|
data.room.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
room_id = uuid.UUID(data.room.name)
|
room_id = uuid.UUID(data.room.name)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -246,6 +258,13 @@ class LiveKitEventsService:
|
|||||||
def _handle_room_finished(self, data):
|
def _handle_room_finished(self, data):
|
||||||
"""Handle 'room_finished' event."""
|
"""Handle 'room_finished' event."""
|
||||||
|
|
||||||
|
if self._is_connection_test_room(data.room.name):
|
||||||
|
logger.info(
|
||||||
|
"Ignoring room_finished event for connection test room '%s'.",
|
||||||
|
data.room.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
room_id = uuid.UUID(data.room.name)
|
room_id = uuid.UUID(data.room.name)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Test LiveKitEvents service.
|
|||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import EgressStatus
|
from livekit.api import EgressStatus
|
||||||
|
|
||||||
@@ -550,6 +552,23 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
|||||||
mock_clear_cache.assert_not_called()
|
mock_clear_cache.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
|
def test_handle_room_finished_ignores_connection_test_room(
|
||||||
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
|
):
|
||||||
|
"""Should ignore room_finished events for connection test rooms."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
|
||||||
|
|
||||||
|
service._handle_room_finished(mock_data)
|
||||||
|
|
||||||
|
mock_delete_dispatch_rule.assert_not_called()
|
||||||
|
mock_clear_cache.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
||||||
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
|
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -600,6 +619,15 @@ def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
|||||||
service._handle_room_started(mock_data)
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
def test_handle_room_started_ignores_connection_test_room(service, settings):
|
||||||
|
"""Should ignore room_started events for connection test rooms."""
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
|
||||||
|
|
||||||
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
|
|
||||||
def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
||||||
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
|
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Test connection test API endpoint."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.api.connection_test import CONNECTION_TEST_USERNAME
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CONNECTION_TEST_TOKEN_TTL_SECONDS=600,
|
||||||
|
CONNECTION_TEST_ROOM_PREFIX="connection-test-",
|
||||||
|
)
|
||||||
|
def test_api_connection_test_returns_ephemeral_livekit_config():
|
||||||
|
"""Each request gets a dedicated room and a short-lived token."""
|
||||||
|
client = APIClient()
|
||||||
|
response_a = client.get("/api/v1.0/connection-test/")
|
||||||
|
response_b = client.get("/api/v1.0/connection-test/")
|
||||||
|
|
||||||
|
assert response_a.status_code == 200
|
||||||
|
assert response_b.status_code == 200
|
||||||
|
|
||||||
|
data_a = response_a.json()
|
||||||
|
data_b = response_b.json()
|
||||||
|
|
||||||
|
room_a = data_a["livekit"]["room"]
|
||||||
|
room_b = data_b["livekit"]["room"]
|
||||||
|
|
||||||
|
assert room_a.startswith("connection-test-")
|
||||||
|
assert room_b.startswith("connection-test-")
|
||||||
|
uuid.UUID(room_a.removeprefix("connection-test-"))
|
||||||
|
uuid.UUID(room_b.removeprefix("connection-test-"))
|
||||||
|
assert room_a != room_b
|
||||||
|
assert data_a["livekit"]["url"]
|
||||||
|
assert data_a["livekit"]["token"]
|
||||||
|
assert data_a["livekit"]["expires_in"] == 600
|
||||||
|
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_TOKEN_TTL_SECONDS=300)
|
||||||
|
def test_api_connection_test_token_is_short_lived_for_user(settings):
|
||||||
|
"""Connection test tokens expire quickly for users."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get("/api/v1.0/connection-test/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
config = response.json()["livekit"]
|
||||||
|
payload = jwt.decode(
|
||||||
|
config["token"],
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"verify_exp": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config["expires_in"] == 300
|
||||||
|
assert payload["video"]["room"] == config["room"]
|
||||||
|
assert payload["name"] == CONNECTION_TEST_USERNAME
|
||||||
|
assert payload["video"]["roomAdmin"] is False
|
||||||
|
assert payload["exp"] - payload["nbf"] == 300
|
||||||
@@ -8,6 +8,7 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
|
|||||||
|
|
||||||
from core.addons import viewsets as addons_viewsets
|
from core.addons import viewsets as addons_viewsets
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
|
from core.api.connection_test import get_connection_test_config
|
||||||
from core.external_api import viewsets as external_viewsets
|
from core.external_api import viewsets as external_viewsets
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
@@ -46,6 +47,11 @@ urlpatterns = [
|
|||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
path("config/", get_frontend_configuration, name="config"),
|
path("config/", get_frontend_configuration, name="config"),
|
||||||
|
path(
|
||||||
|
"connection-test/",
|
||||||
|
get_connection_test_config,
|
||||||
|
name="connection_test",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import mimetypes
|
|||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
from datetime import timedelta
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -68,6 +69,7 @@ def generate_token(
|
|||||||
sources: Optional[List[str]] = None,
|
sources: Optional[List[str]] = None,
|
||||||
is_admin_or_owner: bool = False,
|
is_admin_or_owner: bool = False,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
|
ttl: Optional[timedelta] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a LiveKit access token for a user in a specific room.
|
"""Generate a LiveKit access token for a user in a specific room.
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ def generate_token(
|
|||||||
is_admin_or_owner (bool): Whether user has admin privileges
|
is_admin_or_owner (bool): Whether user has admin privileges
|
||||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||||
used as identity when user.is_anonymous.
|
used as identity when user.is_anonymous.
|
||||||
|
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The LiveKit JWT access token.
|
str: The LiveKit JWT access token.
|
||||||
@@ -131,6 +134,8 @@ def generate_token(
|
|||||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if ttl is not None:
|
||||||
|
token = token.with_ttl(ttl)
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|||||||
@@ -349,6 +349,11 @@ class Base(Configuration):
|
|||||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
|
"connection_test": values.Value(
|
||||||
|
default="30/minute",
|
||||||
|
environ_name="CONNECTION_TEST_THROTTLE_RATES",
|
||||||
|
environ_prefix=None,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
@@ -398,6 +403,9 @@ class Base(Configuration):
|
|||||||
"feedback": values.DictValue(
|
"feedback": values.DictValue(
|
||||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||||
),
|
),
|
||||||
|
"documentation_url": values.Value(
|
||||||
|
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
||||||
|
),
|
||||||
"external_home_url": values.Value(
|
"external_home_url": values.Value(
|
||||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||||
),
|
),
|
||||||
@@ -652,6 +660,16 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
|
||||||
|
600,
|
||||||
|
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
CONNECTION_TEST_ROOM_PREFIX = values.Value(
|
||||||
|
"connection-test-",
|
||||||
|
environ_name="CONNECTION_TEST_ROOM_PREFIX",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ const config: Config = {
|
|||||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
overlayIn: {
|
||||||
|
from: { opacity: 0 },
|
||||||
|
to: { opacity: 0.6 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface ApiConfig {
|
|||||||
feedback: {
|
feedback: {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
documentation_url?: string
|
||||||
external_home_url?: string
|
external_home_url?: string
|
||||||
silence_livekit_debug_logs?: boolean
|
silence_livekit_debug_logs?: boolean
|
||||||
is_silent_login_enabled?: boolean
|
is_silent_login_enabled?: boolean
|
||||||
|
|||||||
@@ -7,36 +7,20 @@ const avatar = cva({
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
context: {
|
context: {
|
||||||
subtitles: {
|
subtitles: { width: '40px', height: '40px' },
|
||||||
width: '40px',
|
list: { width: '32px', height: '32px' },
|
||||||
height: '40px',
|
placeholder: { width: '100%', height: '100%' },
|
||||||
fontSize: '1.3rem',
|
|
||||||
lineHeight: '1rem',
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
fontSize: '1.3rem',
|
|
||||||
lineHeight: '1rem',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
true: {
|
true: { border: '2px solid white' },
|
||||||
border: '2px solid white',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -49,32 +33,35 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
bgColor?: string
|
bgColor?: string
|
||||||
} & RecipeVariantProps<typeof avatar>
|
} & RecipeVariantProps<typeof avatar>
|
||||||
|
|
||||||
export const Avatar = ({
|
export const Avatar = React.memo(
|
||||||
name,
|
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||||
bgColor,
|
const initial = name?.trim()?.charAt(0) ?? ''
|
||||||
context,
|
return (
|
||||||
notification,
|
<div
|
||||||
style,
|
style={{ backgroundColor: bgColor, ...style }}
|
||||||
...props
|
className={avatar({ context, notification })}
|
||||||
}: AvatarProps) => {
|
{...props}
|
||||||
const initial = name?.trim()?.charAt(0) ?? ''
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: bgColor,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
className={avatar({ context, notification })}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={css({
|
|
||||||
marginTop: '-0.3rem',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{initial}
|
<svg
|
||||||
</span>
|
viewBox="0 0 100 100"
|
||||||
</div>
|
aria-hidden="true"
|
||||||
)
|
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||||
}
|
>
|
||||||
|
<text
|
||||||
|
x="50"
|
||||||
|
y="50"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontSize="52"
|
||||||
|
fontWeight="500"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Avatar.displayName = 'Avatar'
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
import { ChatMessages } from './ChatMessages'
|
||||||
|
import { ChatTextArea } from './ChatTextArea'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
const ChatContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
padding: '0 1.5rem',
|
||||||
|
flexGrow: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ChatMessagesContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TextContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
padding: '0.75rem',
|
||||||
|
backgroundColor: 'greyscale.50',
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Chat = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatContainer>
|
||||||
|
<TextContainer>
|
||||||
|
<Text variant="sm">{t('disclaimer')}</Text>
|
||||||
|
</TextContainer>
|
||||||
|
<ChatMessagesContainer>
|
||||||
|
<ChatMessages />
|
||||||
|
</ChatMessagesContainer>
|
||||||
|
<ChatTextArea />
|
||||||
|
</ChatContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ChatRow } from '@/stores/chat'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ChatMessageMetadata } from './ChatMessageMedata'
|
||||||
|
import { ChatMessageBody } from './ChatMessageBody'
|
||||||
|
|
||||||
|
const StyledContainer = styled('li', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.25rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ChatMessageProps = {
|
||||||
|
item: ChatRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatMessage = ({ item }: ChatMessageProps) => {
|
||||||
|
const time = new Date(item.timestamp)
|
||||||
|
const locale = navigator ? navigator.language : 'en-US'
|
||||||
|
return (
|
||||||
|
<StyledContainer
|
||||||
|
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||||
|
>
|
||||||
|
{!item.hideMetadata && (
|
||||||
|
<ChatMessageMetadata
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
identity={item.identity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChatMessageBody message={item.message} />
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ChatRow } from '@/stores/chat'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { formatChatMessageLinks } from '@livekit/components-react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
type ChatMessageBodyProps = Pick<ChatRow, 'message'>
|
||||||
|
|
||||||
|
export const ChatMessageBody = React.memo(
|
||||||
|
({ message }: ChatMessageBodyProps) => {
|
||||||
|
const formattedMessage = useMemo(() => {
|
||||||
|
return formatChatMessageLinks(message)
|
||||||
|
}, [message])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
'& .lk-chat-link': {
|
||||||
|
color: 'blue',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formattedMessage}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatMessageBody.displayName = 'ChatMessageBody'
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ChatRow, chatStore } from '@/stores/chat'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
const StyledContainer = styled('span', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
paddingTop: '0.75rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ChatMessageMetadataProps = Pick<ChatRow, 'identity' | 'timestamp'>
|
||||||
|
|
||||||
|
export const ChatMessageMetadata = React.memo(
|
||||||
|
({ identity, timestamp }: ChatMessageMetadataProps) => {
|
||||||
|
const time = new Date(timestamp)
|
||||||
|
const locale = navigator ? navigator.language : 'en-US'
|
||||||
|
|
||||||
|
const { names } = useSnapshot(chatStore)
|
||||||
|
|
||||||
|
const currentDisplayName = useMemo(() => {
|
||||||
|
if (identity) return names[identity] ?? identity
|
||||||
|
}, [names, identity])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<Text bold={true} variant="sm">
|
||||||
|
{currentDisplayName}
|
||||||
|
</Text>
|
||||||
|
<Text variant="smNote" wrap="no">
|
||||||
|
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||||
|
</Text>
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
ListBox,
|
||||||
|
ListBoxItem,
|
||||||
|
ListLayout,
|
||||||
|
Virtualizer,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ChatMessage } from './ChatMessage.tsx'
|
||||||
|
import { useLayoutEffect, useRef } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { ChatRow, chatStore } from '@/stores/chat'
|
||||||
|
|
||||||
|
// Estimated height of a chat entry in px. ListLayout measures the real
|
||||||
|
// rendered height of each row; this value is only used to size the
|
||||||
|
// scrollbar before rows have been measured.
|
||||||
|
const ESTIMATED_ROW_HEIGHT = 56
|
||||||
|
const BOTTOM_THRESHOLD_PX = 32
|
||||||
|
|
||||||
|
export const ChatMessages = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||||
|
const { rows: items } = useSnapshot(chatStore)
|
||||||
|
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const stick = useRef(true)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = listRef.current
|
||||||
|
if (!el) return
|
||||||
|
const pin = () => {
|
||||||
|
if (stick.current) el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
const onScroll = () => {
|
||||||
|
stick.current =
|
||||||
|
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(pin)
|
||||||
|
// Sizer div = ListLayout's content size. Fires on append, and again each
|
||||||
|
// time an estimated row height is replaced by a measured one.
|
||||||
|
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
||||||
|
// Container. Height goes 0 -> N as the panel animates in, and on resize.
|
||||||
|
ro.observe(el)
|
||||||
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
pin()
|
||||||
|
return () => {
|
||||||
|
ro.disconnect()
|
||||||
|
el.removeEventListener('scroll', onScroll)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Virtualizer
|
||||||
|
layout={ListLayout}
|
||||||
|
layoutOptions={{
|
||||||
|
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
|
||||||
|
gap: 4,
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListBox
|
||||||
|
ref={listRef}
|
||||||
|
aria-label={t('messagesLabel', 'Chat messages')}
|
||||||
|
items={items}
|
||||||
|
selectionMode="none"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item: ChatRow) => (
|
||||||
|
<ListBoxItem style={{ width: '100%' }}>
|
||||||
|
<ChatMessage item={item} />
|
||||||
|
</ListBoxItem>
|
||||||
|
)}
|
||||||
|
</ListBox>
|
||||||
|
</Virtualizer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level
|
||||||
|
import { ref } from 'valtio'
|
||||||
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { useChat, useRoomContext } from '@livekit/components-react'
|
||||||
|
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
|
||||||
|
import type { ChatMessage } from '@livekit/components-core'
|
||||||
|
import {
|
||||||
|
LocalParticipant,
|
||||||
|
Participant,
|
||||||
|
RemoteParticipant,
|
||||||
|
RoomEvent,
|
||||||
|
} from 'livekit-client'
|
||||||
|
|
||||||
|
export const ChatProvider = () => {
|
||||||
|
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
||||||
|
const { send, chatMessages, isSending } = useChat()
|
||||||
|
const { isChatOpen } = useSidePanel()
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetChatStore()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Tigger the message notification (temporary)
|
||||||
|
useEffect(() => {
|
||||||
|
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||||
|
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||||
|
// (see https://github.com/livekit/components-js/issues/1158)
|
||||||
|
// Remove this once we refactor chat to use the new text stream approach
|
||||||
|
const latestMessage = chatMessages.slice(-1)[0]
|
||||||
|
if (!latestMessage) return
|
||||||
|
const from = latestMessage.from as
|
||||||
|
| RemoteParticipant
|
||||||
|
| LocalParticipant
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
room.emit(RoomEvent.ChatMessage, latestMessage, from)
|
||||||
|
}, [chatMessages, room])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (let i = chatStore.rows.length; i < chatMessages.length; i++) {
|
||||||
|
appendRow(chatMessages[i])
|
||||||
|
}
|
||||||
|
}, [chatMessages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatStore.send = ref(send)
|
||||||
|
}, [send])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatStore.isSending = isSending
|
||||||
|
}, [isSending])
|
||||||
|
|
||||||
|
// Set the unread messages count
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatMessages.length === 0) return
|
||||||
|
const last = chatMessages[chatMessages.length - 1]
|
||||||
|
if (isChatOpen) {
|
||||||
|
lastReadMsgAt.current = last.timestamp
|
||||||
|
chatStore.unreadMessages = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chatStore.unreadMessages = chatMessages.filter(
|
||||||
|
(m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current
|
||||||
|
).length
|
||||||
|
}, [chatMessages, isChatOpen])
|
||||||
|
|
||||||
|
// Listen to participant name changes
|
||||||
|
useEffect(() => {
|
||||||
|
const setName = (p: Participant) => {
|
||||||
|
chatStore.names[p.identity] = p.name || p.identity
|
||||||
|
}
|
||||||
|
const onNameChanged = (_name: string, p: Participant) => setName(p)
|
||||||
|
room.on(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||||
|
|
||||||
|
type ChatSubmitButtonProps = {
|
||||||
|
handleSubmit: () => Promise<void>
|
||||||
|
isDisabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatSubmitButton = React.memo(
|
||||||
|
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
invisible
|
||||||
|
variant="tertiaryText"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={t('button.label')}
|
||||||
|
>
|
||||||
|
<RiSendPlane2Fill />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatSubmitButton.displayName = 'ChatSubmitButton'
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { TextArea } from '@/primitives'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import React, { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import {
|
||||||
|
chatStore,
|
||||||
|
clearTextAreaValue,
|
||||||
|
persistTextAreaValue,
|
||||||
|
} from '@/stores/chat'
|
||||||
|
import { ChatSubmitButton } from './ChatSubmitButton'
|
||||||
|
|
||||||
|
const StyledContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
margin: '0.75rem 0 1.5rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
backgroundColor: 'gray.100',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ChatTextArea = () => {
|
||||||
|
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
||||||
|
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||||
|
|
||||||
|
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = inputRef.current
|
||||||
|
if (!el) return
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
el.focus({ preventScroll: true })
|
||||||
|
const end = el.value.length
|
||||||
|
el.setSelectionRange(end, end)
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const text = chatStore.textAreaValue
|
||||||
|
if (!send || !text) return
|
||||||
|
await send(text)
|
||||||
|
inputRef?.current?.focus({ preventScroll: true })
|
||||||
|
clearTextAreaValue()
|
||||||
|
}, [send, inputRef])
|
||||||
|
|
||||||
|
const isDisabled = !textAreaValue.trim() || isSending
|
||||||
|
|
||||||
|
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
||||||
|
return
|
||||||
|
e.preventDefault()
|
||||||
|
await handleSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<TextArea
|
||||||
|
ref={inputRef}
|
||||||
|
value={textAreaValue}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onChange={(e) => {
|
||||||
|
persistTextAreaValue(e.target.value)
|
||||||
|
}}
|
||||||
|
fieldSizing={'content'}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
resize: 'none',
|
||||||
|
height: 'auto',
|
||||||
|
maxHeight: '240px',
|
||||||
|
minHeight: `34px`,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
padding: '7px 10px',
|
||||||
|
}}
|
||||||
|
placeholderStyle="strong"
|
||||||
|
spellCheck={false}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder={t('textArea.placeholder')}
|
||||||
|
aria-label={t('textArea.label')}
|
||||||
|
/>
|
||||||
|
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
|
||||||
|
export type ConnectionTestTokenResponse = {
|
||||||
|
livekit: {
|
||||||
|
url: string
|
||||||
|
room: string
|
||||||
|
token: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchConnectionTestToken = () =>
|
||||||
|
fetchApi<ConnectionTestTokenResponse>('/connection-test/')
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
CheckStatus,
|
||||||
|
ConnectionCheck,
|
||||||
|
createLocalAudioTrack,
|
||||||
|
createLocalVideoTrack,
|
||||||
|
getBrowser,
|
||||||
|
type CheckInfo,
|
||||||
|
type LocalVideoTrack,
|
||||||
|
} from 'livekit-client'
|
||||||
|
import { fetchConnectionTestToken } from '../api/fetchConnectionTestToken'
|
||||||
|
import {
|
||||||
|
createInitialSteps,
|
||||||
|
type ConnectionTestLog,
|
||||||
|
type ConnectionTestStepId,
|
||||||
|
type ConnectionTestStepResult,
|
||||||
|
type ConnectionTestStepStatus,
|
||||||
|
} from '../types'
|
||||||
|
import { openPermissionsDialog } from '@/stores/permissions'
|
||||||
|
|
||||||
|
const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
|
||||||
|
'websocket',
|
||||||
|
'webrtc',
|
||||||
|
'turn',
|
||||||
|
'reconnect',
|
||||||
|
'publishAudio',
|
||||||
|
'publishVideo',
|
||||||
|
]
|
||||||
|
|
||||||
|
const CHECK_STATUS_TO_STEP: Record<CheckStatus, ConnectionTestStepStatus> = {
|
||||||
|
[CheckStatus.IDLE]: 'pending',
|
||||||
|
[CheckStatus.RUNNING]: 'running',
|
||||||
|
[CheckStatus.SUCCESS]: 'success',
|
||||||
|
[CheckStatus.FAILED]: 'failed',
|
||||||
|
[CheckStatus.SKIPPED]: 'skipped',
|
||||||
|
}
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
|
||||||
|
error instanceof Error ? error.message : fallback
|
||||||
|
|
||||||
|
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
|
||||||
|
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
||||||
|
summary: info.description,
|
||||||
|
logs: info.logs,
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
||||||
|
const grouped: Record<MediaDeviceKind, string[]> = {
|
||||||
|
audioinput: [],
|
||||||
|
audiooutput: [],
|
||||||
|
videoinput: [],
|
||||||
|
}
|
||||||
|
for (const device of devices) {
|
||||||
|
grouped[device.kind].push(device.label || device.deviceId)
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConnectionTestRunner = () => {
|
||||||
|
const [steps, setSteps] = useState(createInitialSteps)
|
||||||
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
|
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack | null>(null)
|
||||||
|
const videoTrackRef = useRef<LocalVideoTrack | null>(null)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const updateStep = (
|
||||||
|
id: ConnectionTestStepId,
|
||||||
|
patch: Partial<ConnectionTestStepResult>
|
||||||
|
) => {
|
||||||
|
setSteps((current) =>
|
||||||
|
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopVideoTrack = () => {
|
||||||
|
videoTrackRef.current?.stop()
|
||||||
|
videoTrackRef.current = null
|
||||||
|
setVideoTrack(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipSteps = (
|
||||||
|
ids: ConnectionTestStepId[],
|
||||||
|
summary: string,
|
||||||
|
logs?: ConnectionTestLog[]
|
||||||
|
) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
updateStep(id, { status: 'skipped', summary, logs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true on success, false on failure, null if aborted. */
|
||||||
|
const runStep = async (
|
||||||
|
id: ConnectionTestStepId,
|
||||||
|
signal: AbortSignal,
|
||||||
|
fn: () => Promise<Partial<ConnectionTestStepResult>>
|
||||||
|
): Promise<boolean | null> => {
|
||||||
|
if (signal.aborted) return null
|
||||||
|
|
||||||
|
updateStep(id, { status: 'running', summary: undefined, logs: undefined })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fn()
|
||||||
|
if (signal.aborted) return null
|
||||||
|
// `result.status` overrides when set (LiveKit checks map their own status)
|
||||||
|
updateStep(id, { status: 'success', ...result })
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) return null
|
||||||
|
updateStep(id, {
|
||||||
|
status: 'failed',
|
||||||
|
summary: getErrorMessage(error),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runTest = async () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
const { signal } = controller
|
||||||
|
|
||||||
|
setIsRunning(true)
|
||||||
|
setSteps(createInitialSteps())
|
||||||
|
stopVideoTrack()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runStep('browser', signal, async () => {
|
||||||
|
const browser = getBrowser()
|
||||||
|
if (!browser) throw new Error('Browser not detected')
|
||||||
|
return {
|
||||||
|
summary: `${browser.name} ${browser.version}`,
|
||||||
|
data: {
|
||||||
|
name: browser.name,
|
||||||
|
version: browser.version,
|
||||||
|
os: browser.os,
|
||||||
|
osVersion: browser.osVersion,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
const microphoneOk = await runStep('microphone', signal, async () => {
|
||||||
|
const track = await createLocalAudioTrack()
|
||||||
|
const label =
|
||||||
|
track.mediaStreamTrack.label ||
|
||||||
|
track.mediaStreamTrack.getSettings().deviceId ||
|
||||||
|
''
|
||||||
|
track.stop()
|
||||||
|
return { summary: label, data: { label } }
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
if (!microphoneOk) openPermissionsDialog('audioinput')
|
||||||
|
|
||||||
|
const cameraOk = await runStep('camera', signal, async () => {
|
||||||
|
const track = await createLocalVideoTrack()
|
||||||
|
videoTrackRef.current = track
|
||||||
|
setVideoTrack(track)
|
||||||
|
const settings = track.mediaStreamTrack.getSettings()
|
||||||
|
const label = track.mediaStreamTrack.label || ''
|
||||||
|
return {
|
||||||
|
summary: label,
|
||||||
|
data: {
|
||||||
|
label,
|
||||||
|
width: settings.width,
|
||||||
|
height: settings.height,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
if (!cameraOk) openPermissionsDialog('videoinput')
|
||||||
|
|
||||||
|
await runStep('devices', signal, async () => {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
return {
|
||||||
|
summary: String(devices.length),
|
||||||
|
data: groupDevicesByKind(devices),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
let checker: ConnectionCheck
|
||||||
|
try {
|
||||||
|
const { livekit } = await fetchConnectionTestToken()
|
||||||
|
checker = new ConnectionCheck(livekit.url, livekit.token)
|
||||||
|
} catch (error) {
|
||||||
|
skipSteps(
|
||||||
|
LIVEKIT_STEP_IDS,
|
||||||
|
getErrorMessage(error, 'Failed to fetch test token')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await runStep('websocket', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkWebsocket())
|
||||||
|
)
|
||||||
|
await runStep('webrtc', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkWebRTC())
|
||||||
|
)
|
||||||
|
await runStep('turn', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkTURN())
|
||||||
|
)
|
||||||
|
await runStep('reconnect', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkReconnect())
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!microphoneOk) {
|
||||||
|
skipSteps(['publishAudio'], 'Microphone permission required')
|
||||||
|
} else {
|
||||||
|
await runStep('publishAudio', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkPublishAudio())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cameraOk) {
|
||||||
|
skipSteps(['publishVideo'], 'Camera permission required')
|
||||||
|
} else {
|
||||||
|
stopVideoTrack()
|
||||||
|
await runStep('publishVideo', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkPublishVideo())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
stopVideoTrack()
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
stopVideoTrack()
|
||||||
|
setSteps(createInitialSteps())
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
isRunning,
|
||||||
|
videoTrack,
|
||||||
|
runTest,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
|
import { Screen } from '@/layout/Screen'
|
||||||
|
import { Box, Button, Text, Ul } from '@/primitives'
|
||||||
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
|
import { Center, HStack, VStack } from '@/styled-system/jsx'
|
||||||
|
import { Permissions } from '@/features/rooms/components/Permissions'
|
||||||
|
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
|
||||||
|
import type { ConnectionTestStepId, ConnectionTestStepResult } from '../types'
|
||||||
|
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
|
||||||
|
|
||||||
|
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
|
||||||
|
|
||||||
|
const TestStepItem = ({ step }: { step: ConnectionTestStepResult }) => {
|
||||||
|
const { t } = useTranslation('connectionTest')
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
const hasLogs = Boolean(step.logs?.length)
|
||||||
|
const statusLabel = t(`status.${step.status}`)
|
||||||
|
const stepLabel = t(`steps.${step.id as ConnectionTestStepId}`)
|
||||||
|
|
||||||
|
const statusVariant =
|
||||||
|
step.status === 'failed'
|
||||||
|
? 'warning'
|
||||||
|
: step.status === 'success'
|
||||||
|
? 'body'
|
||||||
|
: 'smNote'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap="0.25rem" alignItems="stretch" width="100%">
|
||||||
|
<HStack
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="flex-start"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Text variant="bodyXsMedium">{stepLabel}</Text>
|
||||||
|
{step.status === 'running' ? (
|
||||||
|
<Spinner size={20} />
|
||||||
|
) : (
|
||||||
|
<Text variant={statusVariant}>{statusLabel}</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
{step.summary && (
|
||||||
|
<Text variant="smNote" margin={false}>
|
||||||
|
{step.summary}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{hasLogs && step.status !== 'pending' && step.status !== 'running' && (
|
||||||
|
<Button
|
||||||
|
variant="secondaryText"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => setShowDetails((open) => !open)}
|
||||||
|
>
|
||||||
|
{t('details')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showDetails && step.logs && (
|
||||||
|
<Ul>
|
||||||
|
{step.logs.map((log, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<Text variant="xsNote" as="span">
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Ul>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectionTest = () => {
|
||||||
|
const { t } = useTranslation('connectionTest')
|
||||||
|
const { steps, isRunning, videoTrack, runTest } = useConnectionTestRunner()
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
|
||||||
|
const hasStarted = steps.some((step) => step.status !== 'pending')
|
||||||
|
const hasFailed = steps.some((step) => step.status === 'failed')
|
||||||
|
const isPublishVideoRunning = steps.some(
|
||||||
|
(step) => step.id === 'publishVideo' && step.status === 'running'
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = videoRef.current
|
||||||
|
if (!element || !videoTrack) return
|
||||||
|
|
||||||
|
videoTrack.attach(element)
|
||||||
|
return () => {
|
||||||
|
videoTrack.detach(element)
|
||||||
|
}
|
||||||
|
}, [videoTrack])
|
||||||
|
|
||||||
|
// LiveKit appends a bare <video> to document.body during publishVideo.
|
||||||
|
// Keep it in the DOM (so the frame check still works) but hide it visually.
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.toggle(
|
||||||
|
HIDE_LIVEKIT_VIDEO_CLASS,
|
||||||
|
isPublishVideoRunning
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
|
||||||
|
}
|
||||||
|
}, [isPublishVideoRunning])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen layout="centered">
|
||||||
|
<Permissions />
|
||||||
|
<CenteredContent title={t('title')} withBackButton>
|
||||||
|
<Center>
|
||||||
|
<VStack gap="1.5rem" maxWidth="36rem" width="100%">
|
||||||
|
<Text as="p" variant="paragraph" centered last>
|
||||||
|
{t('intro')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button variant="primary" onPress={runTest} isDisabled={isRunning}>
|
||||||
|
{hasStarted ? t('reset') : t('runTest')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{videoTrack && (
|
||||||
|
<Center>
|
||||||
|
<video ref={videoRef} autoPlay playsInline muted />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStarted && (
|
||||||
|
<Box variant="light" width="100%">
|
||||||
|
<VStack gap="1rem" alignItems="stretch">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<TestStepItem key={step.id} step={step} />
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFailed && !isRunning && (
|
||||||
|
<Text variant="smNote" centered>
|
||||||
|
{t('help.firewall')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStarted && !isRunning && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => downloadConnectionTestReport(steps)}
|
||||||
|
>
|
||||||
|
{t('downloadReport')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
</CenteredContent>
|
||||||
|
</Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectionTest
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export type ConnectionTestStepId =
|
||||||
|
| 'browser'
|
||||||
|
| 'microphone'
|
||||||
|
| 'camera'
|
||||||
|
| 'devices'
|
||||||
|
| 'websocket'
|
||||||
|
| 'webrtc'
|
||||||
|
| 'turn'
|
||||||
|
| 'reconnect'
|
||||||
|
| 'publishAudio'
|
||||||
|
| 'publishVideo'
|
||||||
|
|
||||||
|
export type ConnectionTestStepStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'running'
|
||||||
|
| 'success'
|
||||||
|
| 'failed'
|
||||||
|
| 'skipped'
|
||||||
|
|
||||||
|
export type ConnectionTestLog = {
|
||||||
|
level: 'info' | 'warning' | 'error'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionTestStepResult = {
|
||||||
|
id: ConnectionTestStepId
|
||||||
|
status: ConnectionTestStepStatus
|
||||||
|
summary?: string
|
||||||
|
logs?: ConnectionTestLog[]
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] = [
|
||||||
|
'browser',
|
||||||
|
'microphone',
|
||||||
|
'camera',
|
||||||
|
'devices',
|
||||||
|
'websocket',
|
||||||
|
'webrtc',
|
||||||
|
'turn',
|
||||||
|
'reconnect',
|
||||||
|
'publishAudio',
|
||||||
|
'publishVideo',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const createInitialSteps = (): ConnectionTestStepResult[] =>
|
||||||
|
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ConnectionTestStepResult } from '../types'
|
||||||
|
|
||||||
|
export type ConnectionTestReport = {
|
||||||
|
generatedAt: string
|
||||||
|
userAgent: string
|
||||||
|
steps: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
status: ConnectionTestStepResult['status']
|
||||||
|
summary?: string
|
||||||
|
logs?: ConnectionTestStepResult['logs']
|
||||||
|
data?: ConnectionTestStepResult['data']
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildConnectionTestReport = (
|
||||||
|
steps: ConnectionTestStepResult[]
|
||||||
|
): ConnectionTestReport => ({
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
steps: Object.fromEntries(
|
||||||
|
steps.map(({ id, status, summary, logs, data }) => [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
...(summary !== undefined ? { summary } : {}),
|
||||||
|
...(logs?.length ? { logs } : {}),
|
||||||
|
...(data !== undefined ? { data } : {}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const downloadConnectionTestReport = (
|
||||||
|
steps: ConnectionTestStepResult[]
|
||||||
|
) => {
|
||||||
|
const report = buildConnectionTestReport(steps)
|
||||||
|
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
|
||||||
|
const blob = new Blob([JSON.stringify(report, null, 2)], {
|
||||||
|
type: 'application/json',
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = `connection-test-${timestamp}.json`
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
|
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||||
|
|
||||||
const MIN_HEIGHT = 130
|
const MIN_HEIGHT = 130
|
||||||
const MIN_WIDTH = 140
|
const MIN_WIDTH = 140
|
||||||
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
|
|||||||
const ASPECT_RATIO = 16 / 10
|
const ASPECT_RATIO = 16 / 10
|
||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
|
type CarouselOrientation = 'vertical' | 'horizontal'
|
||||||
|
|
||||||
|
interface CarouselLayoutState {
|
||||||
|
orientation: CarouselOrientation
|
||||||
|
maxVisibleTiles: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CarouselLayoutObserverProps {
|
||||||
|
asideEl: React.RefObject<HTMLDivElement>
|
||||||
|
orientation?: CarouselOrientation
|
||||||
|
onLayoutChange: (layout: CarouselLayoutState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CarouselLayoutObserver = ({
|
||||||
|
orientation,
|
||||||
|
asideEl,
|
||||||
|
onLayoutChange,
|
||||||
|
}: CarouselLayoutObserverProps) => {
|
||||||
|
const { width, height } = useSize(asideEl)
|
||||||
|
|
||||||
|
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
||||||
|
// container size hovers around a breakpoint. A ref (not state) because
|
||||||
|
// updating it must not trigger a re-render.
|
||||||
|
const prevTilesRef = React.useRef(0)
|
||||||
|
|
||||||
|
const carouselOrientation: CarouselOrientation =
|
||||||
|
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
||||||
|
|
||||||
|
const tileSpan =
|
||||||
|
carouselOrientation === 'vertical'
|
||||||
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||||
|
|
||||||
|
const scrollBarWidth = getScrollBarWidth()
|
||||||
|
|
||||||
|
const availableSpan =
|
||||||
|
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
||||||
|
|
||||||
|
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
||||||
|
|
||||||
|
let maxVisibleTiles: number
|
||||||
|
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
||||||
|
// Within the dead zone: keep the previous count.
|
||||||
|
maxVisibleTiles = Math.round(prevTilesRef.current)
|
||||||
|
} else {
|
||||||
|
maxVisibleTiles = Math.round(tilesThatFit)
|
||||||
|
prevTilesRef.current = tilesThatFit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply cosmetic layout output straight to the DOM.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = asideEl.current
|
||||||
|
if (!el) return
|
||||||
|
el.dataset.lkOrientation = carouselOrientation
|
||||||
|
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
||||||
|
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
||||||
|
|
||||||
|
// Report upward only what the parent actually needs for
|
||||||
|
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
||||||
|
useEffect(() => {
|
||||||
|
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
||||||
|
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
@@ -40,52 +107,42 @@ export function CarouselLayout({
|
|||||||
...props
|
...props
|
||||||
}: CarouselLayoutProps) {
|
}: CarouselLayoutProps) {
|
||||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
|
||||||
const { width, height } = useSize(asideEl)
|
|
||||||
const carouselOrientation = orientation
|
|
||||||
? orientation
|
|
||||||
: height >= width
|
|
||||||
? 'vertical'
|
|
||||||
: 'horizontal'
|
|
||||||
|
|
||||||
const tileSpan =
|
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||||
carouselOrientation === 'vertical'
|
orientation: orientation ?? 'vertical',
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
})
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const tilesThatFit =
|
// Stable callback + identity check: the parent only re-renders when the
|
||||||
carouselOrientation === 'vertical'
|
// derived layout genuinely changed, not on every resize tick.
|
||||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
setLayout((prev) =>
|
||||||
|
prev.orientation === next.orientation &&
|
||||||
|
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||||
|
? prev
|
||||||
|
: next
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
|
||||||
maxVisibleTiles = Math.round(prevTiles)
|
|
||||||
} else if (prevTiles !== tilesThatFit) {
|
|
||||||
setPrevTiles(tilesThatFit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (asideEl.current) {
|
|
||||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
|
||||||
asideEl.current.style.setProperty(
|
|
||||||
'--lk-max-visible-tiles',
|
|
||||||
maxVisibleTiles.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [maxVisibleTiles, carouselOrientation])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
key={carouselOrientation}
|
<CarouselLayoutObserver
|
||||||
className="lk-carousel"
|
asideEl={asideEl}
|
||||||
ref={asideEl}
|
orientation={orientation}
|
||||||
{...props}
|
onLayoutChange={handleLayoutChange}
|
||||||
>
|
/>
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||||
</aside>
|
{/* which resets scroll position and re-runs the observer measurement. */}
|
||||||
|
<aside
|
||||||
|
key={layout.orientation}
|
||||||
|
className="lk-carousel"
|
||||||
|
ref={asideEl}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ParticipantTile } from './ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||||
import type { FocusLayoutProps } from '@livekit/components-react'
|
import type { FocusLayoutProps } from '@livekit/components-react'
|
||||||
|
|
||||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||||
@@ -10,6 +10,36 @@ import { mergeProps } from '@/utils/mergeProps'
|
|||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from './PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||||
|
|
||||||
|
interface GridLayoutObserverProps {
|
||||||
|
gridEl: React.RefObject<HTMLDivElement>
|
||||||
|
trackCount: number
|
||||||
|
onMaxTilesChange: (maxTiles: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless component that runs the layout calculation in isolation and
|
||||||
|
* reports the resulting tile capacity upward.
|
||||||
|
*
|
||||||
|
* `useGridLayout` re-renders its host on every layout recalculation
|
||||||
|
* (e.g. container resizes). Rendering it in a null child means only this
|
||||||
|
* component churns; the parent `GridLayout` re-renders solely when
|
||||||
|
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
||||||
|
*/
|
||||||
|
const GridLayoutObserver = ({
|
||||||
|
gridEl,
|
||||||
|
trackCount,
|
||||||
|
onMaxTilesChange,
|
||||||
|
}: GridLayoutObserverProps) => {
|
||||||
|
const { layout } = useGridLayout(gridEl, trackCount)
|
||||||
|
useEffect(() => {
|
||||||
|
onMaxTilesChange(layout.maxTiles)
|
||||||
|
}, [onMaxTilesChange, layout.maxTiles])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
@@ -37,14 +67,15 @@ export interface GridLayoutProps
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
const gridEl = React.createRef<HTMLDivElement>()
|
const gridEl = useRef<HTMLDivElement>(null)
|
||||||
|
const [maxTiles, setMaxTiles] = useState(1)
|
||||||
|
|
||||||
const elementProps = React.useMemo(
|
const elementProps = React.useMemo(
|
||||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||||
[props]
|
[props]
|
||||||
)
|
)
|
||||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
const pagination = usePagination(maxTiles, tracks)
|
||||||
const pagination = usePagination(layout.maxTiles, tracks)
|
useSpeakerPromotionTrigger(pagination.tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
onLeftSwipe: pagination.nextPage,
|
onLeftSwipe: pagination.nextPage,
|
||||||
@@ -57,8 +88,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|||||||
data-lk-pagination={pagination.totalPageCount > 1}
|
data-lk-pagination={pagination.totalPageCount > 1}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
>
|
>
|
||||||
|
<GridLayoutObserver
|
||||||
|
gridEl={gridEl}
|
||||||
|
trackCount={tracks.length}
|
||||||
|
onMaxTilesChange={setMaxTiles}
|
||||||
|
/>
|
||||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||||
{tracks.length > layout.maxTiles && (
|
{tracks.length > maxTiles && (
|
||||||
<>
|
<>
|
||||||
<PaginationIndicator
|
<PaginationIndicator
|
||||||
totalPageCount={pagination.totalPageCount}
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
|
||||||
|
|
||||||
|
export const PinAnnouncer = () => {
|
||||||
|
usePinAnnouncements()
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { FocusLayoutContainer, useTracks } from '@livekit/components-react'
|
||||||
|
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
||||||
|
import { FocusLayout } from '@/features/layout/components/FocusLayout'
|
||||||
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
|
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||||
|
import {
|
||||||
|
isEqualTrackRef,
|
||||||
|
isTrackReference,
|
||||||
|
log,
|
||||||
|
type TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-core'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export const StageLayout = () => {
|
||||||
|
const lastAutoFocusedScreenShareTrack =
|
||||||
|
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||||
|
|
||||||
|
const tracks = useTracks(
|
||||||
|
[
|
||||||
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||||
|
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||||
|
],
|
||||||
|
{ updateOnlyOn: [], onlySubscribed: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const screenShareTracks = tracks
|
||||||
|
.filter(isTrackReference)
|
||||||
|
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
|
||||||
|
const carouselTracks = tracks.filter(
|
||||||
|
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
||||||
|
)
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||||
|
useEffect(() => {
|
||||||
|
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||||
|
if (
|
||||||
|
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||||
|
lastAutoFocusedScreenShareTrack.current === null
|
||||||
|
) {
|
||||||
|
log.debug('Auto set screen share focus:', {
|
||||||
|
newScreenShareTrack: screenShareTracks[0],
|
||||||
|
})
|
||||||
|
setPinnedTrack(screenShareTracks[0])
|
||||||
|
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
||||||
|
} else if (
|
||||||
|
lastAutoFocusedScreenShareTrack.current &&
|
||||||
|
!screenShareTracks.some(
|
||||||
|
(track) =>
|
||||||
|
track.publication.trackSid ===
|
||||||
|
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
log.debug('Auto clearing screen share focus.')
|
||||||
|
clearPinnedTrack()
|
||||||
|
lastAutoFocusedScreenShareTrack.current = null
|
||||||
|
}
|
||||||
|
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
|
||||||
|
const updatedFocusTrack = tracks.find(
|
||||||
|
(tr) =>
|
||||||
|
tr.participant.identity === pinnedTrackRef.participant.identity &&
|
||||||
|
tr.source === pinnedTrackRef.source
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
updatedFocusTrack !== pinnedTrackRef &&
|
||||||
|
isTrackReference(updatedFocusTrack)
|
||||||
|
) {
|
||||||
|
setPinnedTrack(updatedFocusTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
screenShareTracks
|
||||||
|
.map(
|
||||||
|
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
|
||||||
|
)
|
||||||
|
.join(),
|
||||||
|
pinnedTrackRef?.publication?.trackSid,
|
||||||
|
tracks,
|
||||||
|
])
|
||||||
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!pinnedTrackRef ? (
|
||||||
|
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
||||||
|
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||||
|
<ParticipantTile />
|
||||||
|
</GridLayout>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="lk-focus-layout-wrapper" style={{ height: 'auto' }}>
|
||||||
|
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||||
|
<CarouselLayout
|
||||||
|
tracks={carouselTracks}
|
||||||
|
style={{
|
||||||
|
minWidth: '200px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParticipantTile />
|
||||||
|
</CarouselLayout>
|
||||||
|
<FocusLayout trackRef={pinnedTrackRef} />
|
||||||
|
</FocusLayoutContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
|
export const usePinAnnouncements = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||||
|
const { t: tRooms } = useTranslation('rooms')
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
const lastPinnedIdentityRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const getAnnouncementName = useCallback(
|
||||||
|
(participant?: Participant | null) => {
|
||||||
|
if (!participant) return tRooms('participants.unknown')
|
||||||
|
return participant.isLocal
|
||||||
|
? tRooms('participants.you')
|
||||||
|
: getParticipantName(participant)
|
||||||
|
},
|
||||||
|
[tRooms]
|
||||||
|
)
|
||||||
|
|
||||||
|
const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. unpin
|
||||||
|
if (!pinnedIdentity) {
|
||||||
|
const lastIdentity = lastPinnedIdentityRef.current
|
||||||
|
if (!lastIdentity) return
|
||||||
|
|
||||||
|
const lastParticipant =
|
||||||
|
room.localParticipant.identity === lastIdentity
|
||||||
|
? room.localParticipant
|
||||||
|
: room.remoteParticipants.get(lastIdentity)
|
||||||
|
|
||||||
|
announce(
|
||||||
|
lastParticipant?.isLocal
|
||||||
|
? t('self.unpin')
|
||||||
|
: t('unpin', { name: getAnnouncementName(lastParticipant) })
|
||||||
|
)
|
||||||
|
|
||||||
|
lastPinnedIdentityRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. same pin → do nothing
|
||||||
|
if (lastPinnedIdentityRef.current === pinnedIdentity) return
|
||||||
|
|
||||||
|
// 3. new pin
|
||||||
|
const participant =
|
||||||
|
room.localParticipant.identity === pinnedIdentity
|
||||||
|
? room.localParticipant
|
||||||
|
: room.remoteParticipants.get(pinnedIdentity)
|
||||||
|
|
||||||
|
lastPinnedIdentityRef.current = pinnedIdentity
|
||||||
|
|
||||||
|
announce(
|
||||||
|
participant?.isLocal
|
||||||
|
? t('self.pin')
|
||||||
|
: t('pin', { name: getAnnouncementName(participant) })
|
||||||
|
)
|
||||||
|
}, [pinnedIdentity, announce, getAnnouncementName, room, t])
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { useEffect, useReducer, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tripwire for speaker promotion.
|
||||||
|
*
|
||||||
|
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
||||||
|
* re-render of the host component only when an active speaker has none of
|
||||||
|
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
||||||
|
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
||||||
|
* state and performs the actual swap.
|
||||||
|
*
|
||||||
|
* Speakers already visible are ignored. Everything else costs zero React work.
|
||||||
|
*
|
||||||
|
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
||||||
|
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||||
|
*/
|
||||||
|
export function useSpeakerPromotionTrigger(
|
||||||
|
sortedTiles: TrackReferenceOrPlaceholder[]
|
||||||
|
) {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
||||||
|
|
||||||
|
// Refs so the listener reads current values without re-subscribing
|
||||||
|
// and without itself being a render dependency.
|
||||||
|
const tilesRef = useRef(sortedTiles)
|
||||||
|
tilesRef.current = sortedTiles
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||||
|
const tiles = tilesRef.current
|
||||||
|
const hiddenSpeakerExists = speakers.some(
|
||||||
|
(speaker) =>
|
||||||
|
!tiles.some((t) => t.participant.identity === speaker.identity)
|
||||||
|
)
|
||||||
|
if (hiddenSpeakerExists) {
|
||||||
|
forceRender()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ import { Button as RACButton } from 'react-aria-components'
|
|||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
|
|
||||||
import { useMaybeLayoutContext } from '@livekit/components-react'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
|
||||||
import { type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack, styled } from '@/styled-system/jsx'
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
import { Div } from '@/primitives'
|
import { Div } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
import { setPinnedTrack } from '@/stores/layout'
|
||||||
|
|
||||||
const ClickableToast = styled(RACButton, {
|
const ClickableToast = styled(RACButton, {
|
||||||
base: {
|
base: {
|
||||||
@@ -28,7 +28,6 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
state,
|
state,
|
||||||
ref
|
ref
|
||||||
)
|
)
|
||||||
const layoutContext = useMaybeLayoutContext()
|
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
|
|
||||||
if (!participant) return
|
if (!participant) return
|
||||||
@@ -38,18 +37,13 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
publication: participant.getTrackPublication(Source.Camera),
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
source: Source.Camera,
|
source: Source.Camera,
|
||||||
}
|
}
|
||||||
const pinParticipant = () => {
|
|
||||||
layoutContext?.pin.dispatch?.({
|
|
||||||
msg: 'set_pin',
|
|
||||||
trackReference,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
<ClickableToast
|
<ClickableToast
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
pinParticipant()
|
setPinnedTrack(trackReference)
|
||||||
closeButtonProps.onPress?.(e)
|
closeButtonProps.onPress?.(e)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@ import { RiInfinityLine } from '@remixicon/react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { usePrevious } from '@/hooks/usePrevious'
|
import { usePrevious } from '@/hooks/usePrevious'
|
||||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
import type { WaitingParticipant } from '@/features/participants/api/listWaitingParticipants'
|
||||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
import { useWaitingParticipants } from '@/features/participants/hooks/useWaitingParticipants'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||||
import { NotificationType } from '@/features/notifications'
|
import { NotificationType } from '@/features/notifications'
|
||||||
|
|||||||
+6
-5
@@ -3,10 +3,10 @@ import { Button, Text } from '@/primitives'
|
|||||||
import { useCallback, useMemo, useRef } from 'react'
|
import { useCallback, useMemo, useRef } from 'react'
|
||||||
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useLocalParticipant } from '@livekit/components-react'
|
|
||||||
import { useSize } from '../hooks/useResizeObserver'
|
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
|
import { LocalParticipant } from 'livekit-client'
|
||||||
|
|
||||||
export const FullScreenShareWarning = ({
|
export const FullScreenShareWarning = ({
|
||||||
trackReference,
|
trackReference,
|
||||||
@@ -17,7 +17,6 @@ export const FullScreenShareWarning = ({
|
|||||||
|
|
||||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const { width: containerWidth } = useSize(warningContainerRef)
|
const { width: containerWidth } = useSize(warningContainerRef)
|
||||||
const { localParticipant } = useLocalParticipant()
|
|
||||||
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||||
|
|
||||||
const isFullScreenSharing = useMemo(() => {
|
const isFullScreenSharing = useMemo(() => {
|
||||||
@@ -57,8 +56,10 @@ export const FullScreenShareWarning = ({
|
|||||||
screenSharePreferences.enabled && isFullScreenSharing
|
screenSharePreferences.enabled && isFullScreenSharing
|
||||||
|
|
||||||
const handleStopScreenShare = async () => {
|
const handleStopScreenShare = async () => {
|
||||||
if (!localParticipant.isScreenShareEnabled) return
|
const participant = trackReference.participant
|
||||||
await localParticipant.setScreenShareEnabled(false, {}, {})
|
if (!(participant instanceof LocalParticipant)) return
|
||||||
|
if (!participant.isScreenShareEnabled) return
|
||||||
|
await (participant as LocalParticipant).setScreenShareEnabled(false, {}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDismissWarning = useCallback(() => {
|
const handleDismissWarning = useCallback(() => {
|
||||||
+9
-7
@@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const Hint = styled('div', {
|
const Hint = styled('div', {
|
||||||
@@ -25,15 +25,17 @@ const Hint = styled('div', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export interface KeyboardShortcutHintProps {
|
export interface KeyboardShortcutHintProps {
|
||||||
children: ReactNode
|
hint?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small reusable bubble used to display and announce keyboard shortcuts,
|
* Small reusable bubble used to display and announce keyboard shortcuts,
|
||||||
* typically when an element receives keyboard focus.
|
* typically when an element receives keyboard focus.
|
||||||
*/
|
*/
|
||||||
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
export const KeyboardShortcutHint = React.memo(
|
||||||
children,
|
({ hint }: KeyboardShortcutHintProps) => {
|
||||||
}) => {
|
return <Hint>{hint}</Hint>
|
||||||
return <Hint>{children}</Hint>
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
|
KeyboardShortcutHint.displayName = 'KeyboardShortcutHint'
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
ConnectionQualityIndicator,
|
||||||
|
LockLockedIcon,
|
||||||
|
ScreenShareIcon,
|
||||||
|
useIsEncrypted,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||||
|
import { ParticipantName } from './ParticipantName'
|
||||||
|
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
||||||
|
|
||||||
|
export const ParticipantMetadata = ({
|
||||||
|
displayedName,
|
||||||
|
participant,
|
||||||
|
isScreenShare,
|
||||||
|
}: {
|
||||||
|
displayedName: string
|
||||||
|
participant: Participant
|
||||||
|
isScreenShare: boolean
|
||||||
|
}) => {
|
||||||
|
const isEncrypted = useIsEncrypted(participant)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lk-participant-metadata">
|
||||||
|
<HStack gap={0.25}>
|
||||||
|
{!isScreenShare && <MutedMicIndicator participant={participant} />}
|
||||||
|
<RaisedHandMetadataWrapper
|
||||||
|
participant={participant}
|
||||||
|
enabled={!isScreenShare}
|
||||||
|
>
|
||||||
|
{isScreenShare && (
|
||||||
|
<ScreenShareIcon
|
||||||
|
style={{
|
||||||
|
maxWidth: '20px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isEncrypted && !isScreenShare && (
|
||||||
|
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||||
|
)}
|
||||||
|
<div className="lk-participant-name-wrapper">
|
||||||
|
<ParticipantName
|
||||||
|
displayedName={displayedName}
|
||||||
|
isScreenShare={isScreenShare}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</RaisedHandMetadataWrapper>
|
||||||
|
</HStack>
|
||||||
|
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React, { CSSProperties } from 'react'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const participantNameStyles: CSSProperties = {
|
||||||
|
paddingBottom: '0.1rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantNameScreenShareStyles: CSSProperties = {
|
||||||
|
...participantNameStyles,
|
||||||
|
marginLeft: '0.4rem',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantName = React.memo(
|
||||||
|
({
|
||||||
|
displayedName,
|
||||||
|
isScreenShare = false,
|
||||||
|
}: {
|
||||||
|
displayedName?: string
|
||||||
|
isScreenShare: boolean
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||||
|
if (isScreenShare) {
|
||||||
|
return (
|
||||||
|
<Text variant="sm" style={participantNameScreenShareStyles}>
|
||||||
|
{t('screenShare', { name: displayedName })}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
||||||
|
{displayedName}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ParticipantName.displayName = 'ParticipantName'
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { Avatar } from '@/components/Avatar'
|
||||||
|
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
|
||||||
|
const StyledParticipantPlaceHolder = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
containerType: 'size',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const StyledAvatarWrapper = styled('div', {
|
||||||
|
base: {
|
||||||
|
borderRadius: '50%',
|
||||||
|
aspectRatio: '1 / 1',
|
||||||
|
width: 'min(90cqmin, 160px)',
|
||||||
|
fontSize: 'min(27cqmin, 48px)',
|
||||||
|
'[data-lk-speaking="true"] &': {
|
||||||
|
animation: 'pulse 1s infinite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ParticipantPlaceholderProps = {
|
||||||
|
color: string
|
||||||
|
displayedNamed: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantPlaceholder = React.memo(
|
||||||
|
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
|
||||||
|
const backgroundGradient = useMemo(
|
||||||
|
() => getParticipantBackgroundGradient(color),
|
||||||
|
[color]
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<StyledParticipantPlaceHolder
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
backgroundImage: backgroundGradient,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StyledAvatarWrapper>
|
||||||
|
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
|
||||||
|
</StyledAvatarWrapper>
|
||||||
|
</StyledParticipantPlaceHolder>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
|
||||||
+31
-103
@@ -1,39 +1,32 @@
|
|||||||
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
ConnectionQualityIndicator,
|
|
||||||
LockLockedIcon,
|
|
||||||
ParticipantTileProps,
|
ParticipantTileProps,
|
||||||
ScreenShareIcon,
|
|
||||||
useEnsureTrackRef,
|
useEnsureTrackRef,
|
||||||
useFeatureContext,
|
useFeatureContext,
|
||||||
useIsEncrypted,
|
|
||||||
useMaybeLayoutContext,
|
|
||||||
useMaybeTrackRefContext,
|
useMaybeTrackRefContext,
|
||||||
useParticipantTile,
|
useParticipantTile,
|
||||||
VideoTrack,
|
VideoTrack,
|
||||||
TrackRefContext,
|
TrackRefContext,
|
||||||
ParticipantContextIfNeeded,
|
ParticipantContextIfNeeded,
|
||||||
|
useParticipantInfo,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import React from 'react'
|
|
||||||
import {
|
import {
|
||||||
|
isEqualTrackRef,
|
||||||
isTrackReference,
|
isTrackReference,
|
||||||
isTrackReferencePinned,
|
|
||||||
TrackReferenceOrPlaceholder,
|
TrackReferenceOrPlaceholder,
|
||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { RiHand } from '@remixicon/react'
|
|
||||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
|
||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
import { ParticipantName } from './ParticipantName'
|
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||||
|
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
|
||||||
|
import { ParticipantMetadata } from './ParticipantMetadata'
|
||||||
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
|
|
||||||
export function TrackRefContextIfNeeded(
|
export function TrackRefContextIfNeeded(
|
||||||
props: React.PropsWithChildren<{
|
props: React.PropsWithChildren<{
|
||||||
@@ -73,16 +66,12 @@ export const ParticipantTile: (
|
|||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const trackReference = useEnsureTrackRef(trackRef)
|
const trackReference = useEnsureTrackRef(trackRef)
|
||||||
|
|
||||||
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
||||||
htmlProps,
|
htmlProps,
|
||||||
disableSpeakingIndicator,
|
disableSpeakingIndicator,
|
||||||
onParticipantClick,
|
onParticipantClick,
|
||||||
trackRef: trackReference,
|
trackRef: trackReference,
|
||||||
})
|
})
|
||||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
|
||||||
const layoutContext = useMaybeLayoutContext()
|
|
||||||
|
|
||||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||||
|
|
||||||
const handleSubscribe = React.useCallback(
|
const handleSubscribe = React.useCallback(
|
||||||
@@ -90,28 +79,25 @@ export const ParticipantTile: (
|
|||||||
if (
|
if (
|
||||||
trackReference.source &&
|
trackReference.source &&
|
||||||
!subscribed &&
|
!subscribed &&
|
||||||
layoutContext &&
|
layoutStore.pinnedTrackRef &&
|
||||||
layoutContext.pin.dispatch &&
|
isEqualTrackRef(trackReference, layoutStore.pinnedTrackRef)
|
||||||
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
|
||||||
) {
|
) {
|
||||||
layoutContext.pin.dispatch({ msg: 'clear_pin' })
|
clearPinnedTrack()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[trackReference, layoutContext]
|
[trackReference]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { isHandRaised } = useRaisedHand({
|
|
||||||
participant: trackReference.participant,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
|
||||||
participant: trackReference.participant,
|
|
||||||
})
|
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||||
|
|
||||||
const participantName = getParticipantName(trackReference.participant)
|
const participantColor = getParticipantColor(trackReference.participant)
|
||||||
|
|
||||||
|
const { identity, name } = useParticipantInfo({
|
||||||
|
participant: trackReference.participant,
|
||||||
|
})
|
||||||
|
const participantName = name || identity || 'Unknown'
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
@@ -138,7 +124,9 @@ export const ParticipantTile: (
|
|||||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
<FullScreenShareWarning trackReference={trackReference} />
|
{trackReference.participant.isLocal && (
|
||||||
|
<FullScreenShareWarning trackReference={trackReference} />
|
||||||
|
)}
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{isTrackReference(trackReference) &&
|
{isTrackReference(trackReference) &&
|
||||||
@@ -160,76 +148,16 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
participant={trackReference.participant}
|
color={participantColor}
|
||||||
|
displayedNamed={participantName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!disableMetadata && (
|
{!disableMetadata && (
|
||||||
<div className="lk-participant-metadata">
|
<ParticipantMetadata
|
||||||
<HStack gap={0.25}>
|
displayedName={participantName}
|
||||||
{!isScreenShare && (
|
isScreenShare={isScreenShare}
|
||||||
<MutedMicIndicator
|
participant={trackReference.participant}
|
||||||
participant={trackReference.participant}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="lk-participant-metadata-item"
|
|
||||||
style={{
|
|
||||||
padding: '0.1rem 0.25rem',
|
|
||||||
backgroundColor:
|
|
||||||
isHandRaised && !isScreenShare
|
|
||||||
? firstInQueue
|
|
||||||
? '#fde047'
|
|
||||||
: 'white'
|
|
||||||
: undefined,
|
|
||||||
color:
|
|
||||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
|
||||||
transition: 'background 200ms ease, color 400ms ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isHandRaised && !isScreenShare && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{positionInQueue}</span>
|
|
||||||
<RiHand
|
|
||||||
color="black"
|
|
||||||
size={16}
|
|
||||||
style={{
|
|
||||||
marginRight: '0.4rem',
|
|
||||||
marginLeft: '0.1rem',
|
|
||||||
minWidth: '16px',
|
|
||||||
animationDuration: '300ms',
|
|
||||||
animationName: 'wave_hand',
|
|
||||||
animationIterationCount: '2',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isScreenShare && (
|
|
||||||
<ScreenShareIcon
|
|
||||||
style={{
|
|
||||||
maxWidth: '20px',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isEncrypted && !isScreenShare && (
|
|
||||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
|
||||||
)}
|
|
||||||
<div className="lk-participant-name-wrapper">
|
|
||||||
<ParticipantName
|
|
||||||
isScreenShare={isScreenShare}
|
|
||||||
participant={trackReference.participant}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -241,13 +169,13 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
</TrackRefContextIfNeeded>
|
</TrackRefContextIfNeeded>
|
||||||
<KeyboardShortcutHint>
|
<KeyboardShortcutHint
|
||||||
{t('toolbarHint', {
|
hint={t('toolbarHint', {
|
||||||
shortcut: formatShortcutLabel(
|
shortcut: formatShortcutLabel(
|
||||||
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</KeyboardShortcutHint>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { ReactNode, RefObject, useEffect, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
useRaisedHand,
|
||||||
|
useRaisedHandPosition,
|
||||||
|
} from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { RiHand } from '@remixicon/react'
|
||||||
|
|
||||||
|
const PositionInQueue = React.memo(
|
||||||
|
({ positionInQueue }: { positionInQueue?: number }) => {
|
||||||
|
if (!positionInQueue) return
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.1rem' }}
|
||||||
|
>
|
||||||
|
<span>{positionInQueue}</span>
|
||||||
|
<RiHand
|
||||||
|
color="black"
|
||||||
|
size={16}
|
||||||
|
style={{
|
||||||
|
marginRight: '0.4rem',
|
||||||
|
marginLeft: '0.1rem',
|
||||||
|
minWidth: '16px',
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationName: 'wave_hand',
|
||||||
|
animationIterationCount: '2',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
PositionInQueue.displayName = 'PositionInQueue'
|
||||||
|
|
||||||
|
const RaisedHandActiveItem = ({
|
||||||
|
participant,
|
||||||
|
targetRef,
|
||||||
|
}: {
|
||||||
|
participant: Participant
|
||||||
|
targetRef: RefObject<HTMLElement | null>
|
||||||
|
}) => {
|
||||||
|
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||||
|
participant,
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
const el = targetRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
el.style.backgroundColor = firstInQueue ? '#fde047' : 'white'
|
||||||
|
el.style.color = 'black'
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.style.backgroundColor = ''
|
||||||
|
el.style.color = ''
|
||||||
|
}
|
||||||
|
}, [targetRef, firstInQueue])
|
||||||
|
|
||||||
|
return <PositionInQueue positionInQueue={positionInQueue} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RaisedHandMetadataWrapper = ({
|
||||||
|
participant,
|
||||||
|
enabled = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
participant: Participant
|
||||||
|
enabled?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}) => {
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
const { isHandRaised } = useRaisedHand({ participant })
|
||||||
|
const showHand = isHandRaised && enabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="lk-participant-metadata-item"
|
||||||
|
style={{
|
||||||
|
padding: '0.1rem 0.25rem',
|
||||||
|
transition: 'background 200ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showHand && (
|
||||||
|
<RaisedHandActiveItem participant={participant} targetRef={ref} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||||
|
|
||||||
|
export const EffectsButton = React.memo(() => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={'sm'}
|
||||||
|
variant={'primaryTextDark'}
|
||||||
|
square
|
||||||
|
tooltip={t('effects')}
|
||||||
|
onPress={() => !isEffectsOpen && toggleEffects()}
|
||||||
|
>
|
||||||
|
<RiImageCircleAiFill />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
EffectsButton.displayName = 'EffectsButton'
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
isEqualTrackRef,
|
||||||
|
TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-core'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
||||||
|
|
||||||
|
export const FocusButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
||||||
|
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
||||||
|
>
|
||||||
|
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
|
const { isMuted } = useTrackMutedIndicator({
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
|
import { FocusButton } from './FocusButton'
|
||||||
|
import { EffectsButton } from './EffectsButton'
|
||||||
|
import { MuteButton } from './MuteButton'
|
||||||
|
import { ZoomButton } from './ZoomButton'
|
||||||
|
|
||||||
|
const MOUSE_IDLE_TIME = 3000
|
||||||
|
|
||||||
|
type FadeOverlayProps = {
|
||||||
|
children: ReactNode
|
||||||
|
hasKeyboardFocus: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||||
|
const [active, setActive] = useState(false)
|
||||||
|
const idleTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const clearIdleTimer = () => {
|
||||||
|
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const armIdleTimer = () => {
|
||||||
|
clearIdleTimer()
|
||||||
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
|
setActive(false)
|
||||||
|
}, MOUSE_IDLE_TIME)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActivity = () => {
|
||||||
|
setActive(true)
|
||||||
|
armIdleTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => clearIdleTimer, [])
|
||||||
|
|
||||||
|
const isVisible = hasKeyboardFocus || active
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
left: '0',
|
||||||
|
top: '0',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
})}
|
||||||
|
data-visible={isVisible || undefined}
|
||||||
|
aria-hidden={!isVisible}
|
||||||
|
onMouseEnter={handleActivity}
|
||||||
|
onMouseMove={handleActivity}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
clearIdleTimer()
|
||||||
|
setActive(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isVisible && children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantTileFocus = ({
|
||||||
|
trackRef,
|
||||||
|
hasKeyboardFocus,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
hasKeyboardFocus: boolean
|
||||||
|
}) => {
|
||||||
|
const participant = trackRef.participant
|
||||||
|
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||||
|
const isLocal = participant.isLocal
|
||||||
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
zIndex: 1,
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
display: 'flex',
|
||||||
|
opacity: 0.6,
|
||||||
|
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||||
|
_hover: {
|
||||||
|
opacity: 0.95,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<HStack gap={0.5} padding={0.5}>
|
||||||
|
<FocusButton trackRef={trackRef} />
|
||||||
|
{!isScreenShare ? (
|
||||||
|
<>
|
||||||
|
{isLocal ? (
|
||||||
|
<EffectsButton />
|
||||||
|
) : (
|
||||||
|
canMute && <MuteButton participant={participant} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
</FadeOverlay>
|
||||||
|
)
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiFullscreenLine } from '@remixicon/react'
|
||||||
|
|
||||||
|
export const ZoomButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||||
|
trackRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isFullscreenAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('fullScreen')}
|
||||||
|
onPress={() => toggleFullScreen()}
|
||||||
|
>
|
||||||
|
<RiFullscreenLine />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
+14
-5
@@ -1,23 +1,22 @@
|
|||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useLowerHandParticipants } from '@/features/rooms/api/lowerHandParticipants'
|
import { useLowerHandParticipants } from '../api/lowerHandParticipants'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
|
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||||
|
|
||||||
type LowerAllHandsButtonProps = {
|
type LowerAllHandsButtonProps = {
|
||||||
participants: Array<Participant>
|
participants: Array<Participant>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LowerAllHandsButton = ({
|
const LowerAllHandsButtonInner = ({
|
||||||
participants,
|
participants,
|
||||||
}: LowerAllHandsButtonProps) => {
|
}: LowerAllHandsButtonProps) => {
|
||||||
const { lowerHandParticipants } = useLowerHandParticipants()
|
const { lowerHandParticipants } = useLowerHandParticipants()
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
if (!participants.length) return null
|
||||||
if (!isAdminOrOwner) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -36,3 +35,13 @@ export const LowerAllHandsButton = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LowerAllHandsButton = ({
|
||||||
|
participants,
|
||||||
|
}: LowerAllHandsButtonProps) => {
|
||||||
|
return (
|
||||||
|
<AdminOrOwnerOnly>
|
||||||
|
<LowerAllHandsButtonInner participants={participants} />
|
||||||
|
</AdminOrOwnerOnly>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-5
@@ -16,11 +16,11 @@ import Source = Track.Source
|
|||||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { MuteAlertDialog } from '../../MuteAlertDialog'
|
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||||
import { PinBadge } from './PinBadge'
|
import { PinBadge } from './PinBadge'
|
||||||
|
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||||
|
|
||||||
type MicIndicatorProps = {
|
type MicIndicatorProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
@@ -93,9 +93,7 @@ type ParticipantListItemProps = {
|
|||||||
participant: Participant
|
participant: Participant
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ParticipantListItem = ({
|
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||||
participant,
|
|
||||||
}: ParticipantListItemProps) => {
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
return (
|
return (
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ReactNode, useId, useState } from 'react'
|
||||||
|
import { RiArrowUpSLine } from '@remixicon/react'
|
||||||
|
import { styled, HStack, VStack } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
const Container = styled('div', {
|
||||||
|
base: {
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'greyscale.250',
|
||||||
|
borderRadius: '8px',
|
||||||
|
margin: '0 .625rem 0.9375rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Header = styled('button', {
|
||||||
|
base: {
|
||||||
|
minHeight: '2.5rem',
|
||||||
|
paddingX: '1.25rem',
|
||||||
|
paddingY: '0.5rem',
|
||||||
|
gap: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
fontSize: '1rem',
|
||||||
|
transition: 'background 200ms',
|
||||||
|
borderTopRadius: '7px', // container radius (8) minus its 1px border
|
||||||
|
_hover: { backgroundColor: 'greyscale.50' },
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
isOpen: { false: { borderRadius: '7px' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Chevron = styled(RiArrowUpSLine, {
|
||||||
|
base: { transition: 'transform 200ms', flexShrink: 0 },
|
||||||
|
variants: {
|
||||||
|
isOpen: { false: { transform: 'rotate(180deg)' } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const List = styled(VStack, {
|
||||||
|
base: {
|
||||||
|
borderTop: '1px solid',
|
||||||
|
borderTopColor: 'greyscale.250',
|
||||||
|
alignItems: 'start',
|
||||||
|
minHeight: 0,
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingY: '0.5rem',
|
||||||
|
paddingX: '1rem',
|
||||||
|
gap: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ParticipantsCollapsibleSectionProps = {
|
||||||
|
heading: string
|
||||||
|
count: number
|
||||||
|
action?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantsCollapsibleSection = ({
|
||||||
|
heading,
|
||||||
|
count,
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
}: ParticipantsCollapsibleSectionProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(true)
|
||||||
|
const listId = useId()
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Header
|
||||||
|
type="button"
|
||||||
|
isOpen={isOpen}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls={listId}
|
||||||
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" width="100%">
|
||||||
|
<span>{heading}</span>
|
||||||
|
<span>{count}</span>
|
||||||
|
</HStack>
|
||||||
|
<Chevron size={32} isOpen={isOpen} aria-hidden />
|
||||||
|
</Header>
|
||||||
|
{isOpen && (
|
||||||
|
<List id={listId} role="list">
|
||||||
|
{action}
|
||||||
|
{children}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRemoteParticipants } from '@livekit/components-react'
|
||||||
|
import { srOnly } from '@/styles/a11y'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { RiInfinityLine } from '@remixicon/react'
|
||||||
|
|
||||||
|
const badgeStyles = css({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-.25rem',
|
||||||
|
right: '-.25rem',
|
||||||
|
width: '1.25rem',
|
||||||
|
height: '1.25rem',
|
||||||
|
backgroundColor: 'gray',
|
||||||
|
borderRadius: '50%',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
userSelect: 'none',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Isolated so participant join/leave events only re-render the badge,
|
||||||
|
* not the button, tooltip or shortcut registration.
|
||||||
|
*/
|
||||||
|
export const ParticipantsCount = React.memo(
|
||||||
|
({ describedById }: { describedById: string }) => {
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'controls.participants',
|
||||||
|
})
|
||||||
|
const remoteParticipants = useRemoteParticipants({
|
||||||
|
updateOnlyOn: [],
|
||||||
|
})
|
||||||
|
const count = (remoteParticipants?.length ?? 0) + 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span id={describedById} className={srOnly}>
|
||||||
|
{t('count', { count })}
|
||||||
|
</span>
|
||||||
|
<div className={badgeStyles} aria-hidden>
|
||||||
|
{count < 100 ? count : <RiInfinityLine size={10} />}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ParticipantsCount.displayName = 'ParticipantsCount'
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import {
|
||||||
|
useRemoteParticipants,
|
||||||
|
useRoomContext,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
|
import { Div, H } from '@/primitives'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ParticipantRow } from './ParticipantRow'
|
||||||
|
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
||||||
|
import { LowerAllHandsButton } from './LowerAllHandsButton'
|
||||||
|
import { MuteEveryoneButton } from './MuteEveryoneButton'
|
||||||
|
import { WaitingParticipantsSection } from './WaitingParticipantsSection'
|
||||||
|
import { RaisedHandRow } from './RaisedHandRow'
|
||||||
|
|
||||||
|
const JoinedParticipantsSections = () => {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||||
|
|
||||||
|
// `updateOnlyOn` filters on the room event *type*, not on which participant
|
||||||
|
// the event is about. `ParticipantNameChanged` and `ParticipantAttributesChanged`
|
||||||
|
// are room-wide and fire for the local participant too, so this hook re-emits
|
||||||
|
// (and we re-render) on local changes, despite its name.
|
||||||
|
//
|
||||||
|
// That is deliberate here: we prepend `room.localParticipant` to the list below,
|
||||||
|
// and its hand-raise state comes from `attributes.handRaisedAt`. Without the
|
||||||
|
// local events waking this component up, raising your own hand would not
|
||||||
|
// update the UI. Do not "optimise" these away.
|
||||||
|
const remoteParticipants = useRemoteParticipants({
|
||||||
|
updateOnlyOn: [
|
||||||
|
RoomEvent.ParticipantNameChanged,
|
||||||
|
RoomEvent.ParticipantAttributesChanged,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const allParticipants = [
|
||||||
|
room.localParticipant,
|
||||||
|
...[...remoteParticipants].sort((a, b) =>
|
||||||
|
(a.name || a.identity).localeCompare(b.name || b.identity)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const participantsWithRaisedHands = allParticipants
|
||||||
|
.filter((p) => !!p.attributes.handRaisedAt)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const raisedAtA = Date.parse(a.attributes.handRaisedAt!)
|
||||||
|
const raisedAtB = Date.parse(b.attributes.handRaisedAt!)
|
||||||
|
return (
|
||||||
|
(Number.isNaN(raisedAtA) ? Infinity : raisedAtA) -
|
||||||
|
(Number.isNaN(raisedAtB) ? Infinity : raisedAtB)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{participantsWithRaisedHands.length > 0 && (
|
||||||
|
<ParticipantsCollapsibleSection
|
||||||
|
heading={t('raisedHands')}
|
||||||
|
count={participantsWithRaisedHands.length}
|
||||||
|
action={
|
||||||
|
<LowerAllHandsButton participants={participantsWithRaisedHands} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{participantsWithRaisedHands.map((p) => (
|
||||||
|
<RaisedHandRow key={p.identity} participant={p} />
|
||||||
|
))}
|
||||||
|
</ParticipantsCollapsibleSection>
|
||||||
|
)}
|
||||||
|
<ParticipantsCollapsibleSection
|
||||||
|
heading={t('contributors')}
|
||||||
|
count={allParticipants.length}
|
||||||
|
action={<MuteEveryoneButton participants={remoteParticipants} />}
|
||||||
|
>
|
||||||
|
{allParticipants.map((p) => (
|
||||||
|
<ParticipantRow key={p.identity} participant={p} />
|
||||||
|
))}
|
||||||
|
</ParticipantsCollapsibleSection>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParticipantsList = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Div overflowY="scroll">
|
||||||
|
<H
|
||||||
|
lvl={2}
|
||||||
|
className={css({
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'greyscale.600',
|
||||||
|
padding: '0 1.5rem',
|
||||||
|
marginBottom: '0.83em',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('subheading').toUpperCase()}
|
||||||
|
</H>
|
||||||
|
<WaitingParticipantsSection />
|
||||||
|
<JoinedParticipantsSections />
|
||||||
|
</Div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Participant, Track } from 'livekit-client'
|
||||||
|
import { RiPushpin2Fill } from '@remixicon/react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
import { isEqualTrackRef } from '@livekit/components-core'
|
||||||
|
import Source = Track.Source
|
||||||
|
|
||||||
|
export const PinBadge = ({ participant }: { participant: Participant }) => {
|
||||||
|
const cameraTrackRef = useMemo(() => {
|
||||||
|
return {
|
||||||
|
participant: participant,
|
||||||
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
|
source: Source.Camera,
|
||||||
|
}
|
||||||
|
}, [participant])
|
||||||
|
|
||||||
|
const screenShareTrackRef = useMemo(() => {
|
||||||
|
return {
|
||||||
|
participant: participant,
|
||||||
|
publication: participant.getTrackPublication(Source.ScreenShare),
|
||||||
|
source: Source.ScreenShare,
|
||||||
|
}
|
||||||
|
}, [participant])
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
const inFocus =
|
||||||
|
isEqualTrackRef(pinnedTrackRef, cameraTrackRef) ||
|
||||||
|
isEqualTrackRef(pinnedTrackRef, screenShareTrackRef)
|
||||||
|
|
||||||
|
if (!inFocus) return
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
height: '18px',
|
||||||
|
width: '18px',
|
||||||
|
borderRadius: '100%',
|
||||||
|
background: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-2px',
|
||||||
|
right: '-4px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiPushpin2Fill size={14} aria-hidden />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+31
-21
@@ -4,27 +4,47 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { Text } from '@/primitives/Text'
|
import { Text } from '@/primitives/Text'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useLowerHandParticipant } from '@/features/rooms/api/lowerHandParticipant'
|
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { isLocal } from '@/utils/livekit'
|
import { isLocal } from '@/utils/livekit'
|
||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
|
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||||
|
|
||||||
|
const ActionButton = ({
|
||||||
|
participant,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
participant: Participant
|
||||||
|
name: string
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
const { lowerHandParticipant } = useLowerHandParticipant()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
variant="greyscale"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => lowerHandParticipant(participant)}
|
||||||
|
aria-label={t('participants.lowerParticipantHand', { name })}
|
||||||
|
tooltip={t('participants.lowerParticipantHand', { name })}
|
||||||
|
data-attr="participants-lower-hand"
|
||||||
|
>
|
||||||
|
<RiHand />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type HandRaisedListItemProps = {
|
type HandRaisedListItemProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HandRaisedListItem = ({
|
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||||
participant,
|
|
||||||
}: HandRaisedListItemProps) => {
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
const { lowerHandParticipant } = useLowerHandParticipant()
|
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -69,19 +89,9 @@ export const HandRaisedListItem = ({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
{isAdminOrOwner && (
|
<AdminOrOwnerOnly>
|
||||||
<Button
|
<ActionButton participant={participant} name={name} />
|
||||||
square
|
</AdminOrOwnerOnly>
|
||||||
variant="greyscale"
|
|
||||||
size="sm"
|
|
||||||
onPress={() => lowerHandParticipant(participant)}
|
|
||||||
aria-label={t('participants.lowerParticipantHand', { name })}
|
|
||||||
tooltip={t('participants.lowerParticipantHand', { name })}
|
|
||||||
data-attr="participants-lower-hand"
|
|
||||||
>
|
|
||||||
<RiHand />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+2
-2
@@ -3,10 +3,10 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
import { WaitingParticipant } from '../api/listWaitingParticipants'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
|
||||||
export const WaitingParticipantListItem = ({
|
export const WaitingParticipantRow = ({
|
||||||
participant,
|
participant,
|
||||||
onAction,
|
onAction,
|
||||||
}: {
|
}: {
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ParticipantsCollapsibleSection } from './ParticipantsCollapsibleSection'
|
||||||
|
import { WaitingParticipantRow } from './WaitingParticipantRow'
|
||||||
|
import { useWaitingParticipants } from '../hooks/useWaitingParticipants'
|
||||||
|
|
||||||
|
export const WaitingParticipantsSection = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants.waiting' })
|
||||||
|
|
||||||
|
const { waitingParticipants, handleParticipantEntry } =
|
||||||
|
useWaitingParticipants()
|
||||||
|
|
||||||
|
if (waitingParticipants?.length == 0) return
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParticipantsCollapsibleSection
|
||||||
|
heading={t('title')}
|
||||||
|
count={waitingParticipants.length}
|
||||||
|
>
|
||||||
|
{waitingParticipants.map((p) => (
|
||||||
|
<WaitingParticipantRow
|
||||||
|
key={p.id}
|
||||||
|
participant={p}
|
||||||
|
onAction={handleParticipantEntry}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ParticipantsCollapsibleSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { Menu as RACMenu } from 'react-aria-components'
|
import { Menu as RACMenu } from 'react-aria-components'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { PinMenuItem } from './PinMenuItem'
|
import { PinMenuItem } from './items/PinMenuItem'
|
||||||
import { RemoveMenuItem } from './RemoveMenuItem'
|
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
||||||
|
|
||||||
export const ParticipantMenu = ({
|
export const ParticipantMenu = ({
|
||||||
participant,
|
participant,
|
||||||
+17
-4
@@ -1,15 +1,28 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
import { Participant, Track } from 'livekit-client'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||||
|
import { isEqualTrackRef } from '@livekit/components-core'
|
||||||
|
import Source = Track.Source
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
||||||
|
|
||||||
const { toggle, inFocus } = useFocusToggleParticipant(participant)
|
const trackRef = useMemo(() => {
|
||||||
|
return {
|
||||||
|
participant: participant,
|
||||||
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
|
source: Source.Camera,
|
||||||
|
}
|
||||||
|
}, [participant])
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
const inFocus = isEqualTrackRef(pinnedTrackRef, trackRef)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -17,7 +30,7 @@ export const PinMenuItem = ({ participant }: { participant: Participant }) => {
|
|||||||
name: participant.name,
|
name: participant.name,
|
||||||
})}
|
})}
|
||||||
className={menuRecipe({ icon: true }).item}
|
className={menuRecipe({ icon: true }).item}
|
||||||
onAction={toggle}
|
onAction={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
||||||
>
|
>
|
||||||
<HStack gap={0.25}>
|
<HStack gap={0.25}>
|
||||||
{inFocus ? (
|
{inFocus ? (
|
||||||
+1
-1
@@ -7,7 +7,7 @@ import { useEnterRoom } from '../api/enterRoom'
|
|||||||
import {
|
import {
|
||||||
useListWaitingParticipants,
|
useListWaitingParticipants,
|
||||||
type WaitingParticipant,
|
type WaitingParticipant,
|
||||||
} from '../api/listWaitingParticipants'
|
} from '../../participants/api/listWaitingParticipants'
|
||||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
type PipFocusLayoutProps = {
|
type PipFocusLayoutProps = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo, useMemo, useRef } from 'react'
|
import { memo, useMemo, useRef } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
import { usePipElementSize } from '../../hooks/usePipElementSize'
|
||||||
import { computePipGridLayout } from '../../utils/pipGrid'
|
import { computePipGridLayout } from '../../utils/pipGrid'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||||
|
|
||||||
type PipScreenShareLayoutProps = {
|
type PipScreenShareLayoutProps = {
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { useLocalParticipant } from '@livekit/components-react'
|
import { useTrackToggle } from '@livekit/components-react'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
|
||||||
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { t } = useTranslation('rooms', {
|
const { t } = useTranslation('rooms', {
|
||||||
keyPrefix: 'pictureInPicture',
|
keyPrefix: 'pictureInPicture',
|
||||||
})
|
})
|
||||||
const { localParticipant } = useLocalParticipant()
|
const { enabled: isScreenSharing } = useTrackToggle({
|
||||||
|
source: Track.Source.ScreenShare,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={t('stage')}
|
aria-label={t('stage')}
|
||||||
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
|
{...(!isScreenSharing ? { inert: '' } : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { VStack } from '@/styled-system/jsx'
|
|||||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useSettingsDialog,
|
|
||||||
SettingsDialogExtendedKey,
|
SettingsDialogExtendedKey,
|
||||||
useTranscriptionLanguage,
|
useTranscriptionLanguage,
|
||||||
} from '@/features/settings'
|
} from '@/features/settings'
|
||||||
@@ -35,6 +34,7 @@ import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnab
|
|||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
import { LimitDescription } from './LimitDescription'
|
import { LimitDescription } from './LimitDescription'
|
||||||
|
import { openSettingsDialog } from '@/stores/settings'
|
||||||
|
|
||||||
export const TranscriptSidePanel = () => {
|
export const TranscriptSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -48,8 +48,6 @@ export const TranscriptSidePanel = () => {
|
|||||||
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
||||||
useTranscriptionLanguage()
|
useTranscriptionLanguage()
|
||||||
|
|
||||||
const { openSettingsDialog } = useSettingsDialog()
|
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
FeatureFlags.Transcript
|
FeatureFlags.Transcript
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
const StyledContainer = styled('div', {
|
const StyledContainer = styled('div', {
|
||||||
@@ -67,35 +68,36 @@ export type ActiveSpeakerProps = {
|
|||||||
pushToTalk?: boolean
|
pushToTalk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActiveSpeaker = ({
|
export const ActiveSpeaker = React.memo(
|
||||||
isSpeaking,
|
({ isSpeaking, pushToTalk }: ActiveSpeakerProps) => {
|
||||||
pushToTalk,
|
return (
|
||||||
}: ActiveSpeakerProps) => {
|
<StyledContainer pushToTalk={pushToTalk}>
|
||||||
return (
|
<StyledChild
|
||||||
<StyledContainer pushToTalk={pushToTalk}>
|
pushToTalk={pushToTalk}
|
||||||
<StyledChild
|
active={isSpeaking}
|
||||||
pushToTalk={pushToTalk}
|
size="small"
|
||||||
active={isSpeaking}
|
style={{
|
||||||
size="small"
|
animationDelay: '300ms',
|
||||||
style={{
|
}}
|
||||||
animationDelay: '300ms',
|
/>
|
||||||
}}
|
<StyledChild
|
||||||
/>
|
pushToTalk={pushToTalk}
|
||||||
<StyledChild
|
active={isSpeaking}
|
||||||
pushToTalk={pushToTalk}
|
style={{
|
||||||
active={isSpeaking}
|
animationDelay: '100ms',
|
||||||
style={{
|
}}
|
||||||
animationDelay: '100ms',
|
/>
|
||||||
}}
|
<StyledChild
|
||||||
/>
|
pushToTalk={pushToTalk}
|
||||||
<StyledChild
|
active={isSpeaking}
|
||||||
pushToTalk={pushToTalk}
|
size="small"
|
||||||
active={isSpeaking}
|
style={{
|
||||||
size="small"
|
animationDelay: '500ms',
|
||||||
style={{
|
}}
|
||||||
animationDelay: '500ms',
|
/>
|
||||||
}}
|
</StyledContainer>
|
||||||
/>
|
)
|
||||||
</StyledContainer>
|
}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
ActiveSpeaker.displayName = 'ActiveSpeaker'
|
||||||
|
|||||||
@@ -170,7 +170,6 @@ export const Conference = ({
|
|||||||
prepareConnection()
|
prepareConnection()
|
||||||
}, [room, apiConfig, isConnectionWarmedUp])
|
}, [room, apiConfig, isConnectionWarmedUp])
|
||||||
|
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
|
||||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||||
error: MediaDeviceFailure | null
|
error: MediaDeviceFailure | null
|
||||||
kind: MediaDeviceKind | null
|
kind: MediaDeviceKind | null
|
||||||
@@ -303,13 +302,7 @@ export const Conference = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{showInviteDialog && !isMobile && (
|
{!isMobile && <InviteDialog mode={mode} />}
|
||||||
<InviteDialog
|
|
||||||
isOpen={showInviteDialog}
|
|
||||||
onOpenChange={setShowInviteDialog}
|
|
||||||
onClose={() => setShowInviteDialog(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MediaDeviceErrorAlert
|
<MediaDeviceErrorAlert
|
||||||
{...mediaDeviceError}
|
{...mediaDeviceError}
|
||||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
|
import { Div, Button, P, Bold } from '@/primitives'
|
||||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
import { Heading, Dialog } from 'react-aria-components'
|
import { Heading, Dialog } from 'react-aria-components'
|
||||||
import { Text, text } from '@/primitives/Text'
|
import { Text, text } from '@/primitives/Text'
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiSpam2Fill,
|
RiSpam2Fill,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
@@ -39,7 +39,9 @@ const StyledRACDialog = styled(Dialog, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||||
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
@@ -58,163 +60,161 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
copyRoomUrlToClipboard,
|
copyRoomUrlToClipboard,
|
||||||
} = useCopyRoomToClipboard(roomData)
|
} = useCopyRoomToClipboard(roomData)
|
||||||
|
|
||||||
|
if (!showInviteDialog) return null
|
||||||
return (
|
return (
|
||||||
<StyledRACDialog {...props}>
|
<StyledRACDialog>
|
||||||
{({ close }) => (
|
<VStack
|
||||||
<VStack
|
alignItems="left"
|
||||||
alignItems="left"
|
justify="start"
|
||||||
justify="start"
|
gap={0}
|
||||||
gap={0}
|
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
>
|
||||||
>
|
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
{t('heading')}
|
||||||
{t('heading')}
|
</Heading>
|
||||||
</Heading>
|
<Div position="absolute" top="5" right="5">
|
||||||
<Div position="absolute" top="5" right="5">
|
<Button
|
||||||
<Button
|
invisible
|
||||||
invisible
|
variant="tertiaryText"
|
||||||
variant="tertiaryText"
|
size="xs"
|
||||||
size="xs"
|
onPress={() => {
|
||||||
onPress={() => {
|
setShowInviteDialog(false)
|
||||||
props.onClose?.()
|
}}
|
||||||
close()
|
aria-label={t('closeDialog')}
|
||||||
}}
|
>
|
||||||
aria-label={t('closeDialog')}
|
<RiCloseLine />
|
||||||
>
|
</Button>
|
||||||
<RiCloseLine />
|
</Div>
|
||||||
</Button>
|
<P>{t('description')}</P>
|
||||||
</Div>
|
{isTelephonyReadyForUse ? (
|
||||||
<P>{t('description')}</P>
|
<div
|
||||||
{isTelephonyReadyForUse ? (
|
className={css({
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
gap: '1rem',
|
||||||
|
overflow: 'visible',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
marginTop: '0.5rem',
|
justifyContent: 'space-between',
|
||||||
gap: '1rem',
|
|
||||||
overflow: 'visible',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<Text as="p" wrap="pretty">
|
||||||
className={css({
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
display: 'flex',
|
</Text>
|
||||||
alignItems: 'center',
|
{isTelephonyReadyForUse && roomUrl && (
|
||||||
justifyContent: 'space-between',
|
<Button
|
||||||
})}
|
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||||
>
|
square
|
||||||
<Text as="p" wrap="pretty">
|
size={'sm'}
|
||||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
onPress={copyRoomUrlToClipboard}
|
||||||
</Text>
|
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
{isTelephonyReadyForUse && roomUrl && (
|
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
<Button
|
>
|
||||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
{isRoomUrlCopied ? (
|
||||||
square
|
<RiCheckLine aria-hidden="true" />
|
||||||
size={'sm'}
|
) : (
|
||||||
onPress={copyRoomUrlToClipboard}
|
<RiFileCopyLine aria-hidden="true" />
|
||||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
)}
|
||||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
</Button>
|
||||||
>
|
)}
|
||||||
{isRoomUrlCopied ? (
|
|
||||||
<RiCheckLine aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<RiFileCopyLine aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Text as="p" wrap="pretty">
|
|
||||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
|
||||||
{telephony?.internationalPhoneNumber}
|
|
||||||
</Text>
|
|
||||||
<Text as="p" wrap="pretty">
|
|
||||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
|
||||||
{formatPinCode(roomData?.pin_code)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={isCopied ? 'success' : 'secondaryText'}
|
|
||||||
size="sm"
|
|
||||||
fullWidth
|
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
|
||||||
style={{
|
|
||||||
justifyContent: 'start',
|
|
||||||
}}
|
|
||||||
onPress={copyRoomToClipboard}
|
|
||||||
data-attr="share-dialog-copy"
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
<RiCheckLine
|
|
||||||
size={18}
|
|
||||||
style={{ marginRight: '8px' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copied')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiFileCopyLine
|
|
||||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{t('copy')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||||
|
{telephony?.internationalPhoneNumber}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||||
|
{formatPinCode(roomData?.pin_code)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={isCopied ? 'success' : 'tertiary'}
|
variant={isCopied ? 'success' : 'secondaryText'}
|
||||||
|
size="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-label={isCopied ? t('copied') : t('copy')}
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
onPress={copyRoomToClipboard}
|
onPress={copyRoomToClipboard}
|
||||||
data-attr="share-dialog-copy"
|
data-attr="share-dialog-copy"
|
||||||
>
|
>
|
||||||
{isCopied ? (
|
{isCopied ? (
|
||||||
<>
|
<>
|
||||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
<RiCheckLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{t('copied')}
|
{t('copied')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
<RiFileCopyLine
|
||||||
{t('copyUrl')}
|
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('copy')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
) : (
|
||||||
<HStack>
|
<Button
|
||||||
<div
|
variant={isCopied ? 'success' : 'tertiary'}
|
||||||
|
fullWidth
|
||||||
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
|
onPress={copyRoomToClipboard}
|
||||||
|
data-attr="share-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||||
|
{t('copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||||
|
{t('copyUrl')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||||
|
<HStack>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'primary.200',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '4px',
|
||||||
|
marginTop: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiSpam2Fill
|
||||||
|
size={22}
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primary.200',
|
fill: 'primary.500',
|
||||||
borderRadius: '50%',
|
|
||||||
padding: '4px',
|
|
||||||
marginTop: '1rem',
|
|
||||||
})}
|
})}
|
||||||
>
|
/>
|
||||||
<RiSpam2Fill
|
</div>
|
||||||
size={22}
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
className={css({
|
{t('permissions')}
|
||||||
fill: 'primary.500',
|
</Text>
|
||||||
})}
|
</HStack>
|
||||||
/>
|
)}
|
||||||
</div>
|
</VStack>
|
||||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
|
||||||
{t('permissions')}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</StyledRACDialog>
|
</StyledRACDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-9
@@ -1,27 +1,35 @@
|
|||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||||
import {
|
import {
|
||||||
useRemoteParticipants,
|
useRemoteParticipants,
|
||||||
useRoomContext,
|
useRoomContext,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
|
||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useIsAdvancedConnectionObserverEnabled } from './useIsAdvancedConnectionObserverEnabled'
|
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||||
|
|
||||||
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
|
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
|
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
||||||
|
|
||||||
export const useConnectionObserver = () => {
|
export const ConnectionObserver = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const connectionStartTimeRef = useRef<number | null>(null)
|
const connectionStartTimeRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
|
|
||||||
|
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
||||||
|
const isMobile = isMobileBrowser()
|
||||||
|
|
||||||
const isAdvancedConnectionObserverEnabled =
|
const isAdvancedConnectionObserverEnabled =
|
||||||
useIsAdvancedConnectionObserverEnabled()
|
!isMobile && isAnalyticsEnabled && featureEnabled
|
||||||
|
|
||||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||||
|
|
||||||
@@ -232,4 +240,6 @@ export const useConnectionObserver = () => {
|
|||||||
connectionStartTimeRef.current = null
|
connectionStartTimeRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,9 @@ import { HStack } from '@/styled-system/jsx'
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
import { useHumanizeDuration } from '@/hooks/useHumanizeDuration'
|
||||||
|
import { openSettingsDialog } from '@/stores/settings'
|
||||||
|
|
||||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
||||||
@@ -94,7 +94,6 @@ const Description = () => {
|
|||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||||
const { openSettingsDialog } = useSettingsDialog()
|
|
||||||
return (
|
return (
|
||||||
<P>
|
<P>
|
||||||
{t('settingsPrefix')}{' '}
|
{t('settingsPrefix')}{' '}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import type { CSSProperties } from 'react'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useParticipantInfo } from '@livekit/components-react'
|
|
||||||
import type { Participant } from 'livekit-client'
|
|
||||||
|
|
||||||
const participantNameStyles: CSSProperties = {
|
|
||||||
paddingBottom: '0.1rem',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}
|
|
||||||
|
|
||||||
const participantNameScreenShareStyles: CSSProperties = {
|
|
||||||
...participantNameStyles,
|
|
||||||
marginLeft: '0.4rem',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantName = ({
|
|
||||||
participant,
|
|
||||||
isScreenShare = false,
|
|
||||||
}: {
|
|
||||||
participant: Participant
|
|
||||||
isScreenShare: boolean
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
|
||||||
|
|
||||||
const { identity, name } = useParticipantInfo({ participant })
|
|
||||||
const displayedName = name != '' ? name : identity
|
|
||||||
|
|
||||||
if (isScreenShare) {
|
|
||||||
return (
|
|
||||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
|
||||||
{t('screenShare', { name: displayedName })}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
|
||||||
{displayedName}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { Avatar } from '@/components/Avatar'
|
|
||||||
import { useIsSpeaking } from '@livekit/components-react'
|
|
||||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
|
||||||
import { useMemo, useRef } from 'react'
|
|
||||||
|
|
||||||
const StyledParticipantPlaceHolder = styled('div', {
|
|
||||||
base: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ParticipantPlaceholderProps = {
|
|
||||||
participant: Participant
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ParticipantPlaceholder = ({
|
|
||||||
participant,
|
|
||||||
}: ParticipantPlaceholderProps) => {
|
|
||||||
const isSpeaking = useIsSpeaking(participant)
|
|
||||||
const participantColor = getParticipantColor(participant)
|
|
||||||
const backgroundGradient = useMemo(
|
|
||||||
() => getParticipantBackgroundGradient(participantColor),
|
|
||||||
[participantColor]
|
|
||||||
)
|
|
||||||
|
|
||||||
const placeholderEl = useRef<HTMLDivElement>(null)
|
|
||||||
const { width, height } = useSize(placeholderEl)
|
|
||||||
|
|
||||||
const minDimension = Math.min(width, height)
|
|
||||||
const avatarSize = useMemo(
|
|
||||||
() => Math.min(Math.round(minDimension * 0.9), 160),
|
|
||||||
[minDimension]
|
|
||||||
)
|
|
||||||
|
|
||||||
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledParticipantPlaceHolder
|
|
||||||
ref={placeholderEl}
|
|
||||||
style={{
|
|
||||||
backgroundColor: participantColor,
|
|
||||||
backgroundImage: backgroundGradient,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: isSpeaking ? 'pulse 1s infinite' : undefined,
|
|
||||||
height: 'auto',
|
|
||||||
aspectRatio: '1/1',
|
|
||||||
width: '80%',
|
|
||||||
maxWidth: `${avatarSize}px`,
|
|
||||||
fontSize: `${initialSize}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/*fixme - participant doesn't update on ParticipantNameChanged event */}
|
|
||||||
<Avatar
|
|
||||||
name={participant.name}
|
|
||||||
bgColor={participantColor}
|
|
||||||
context="placeholder"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</StyledParticipantPlaceHolder>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import {
|
|
||||||
RiFullscreenLine,
|
|
||||||
RiImageCircleAiFill,
|
|
||||||
RiMicLine,
|
|
||||||
RiMicOffLine,
|
|
||||||
RiPushpin2Line,
|
|
||||||
RiUnpinLine,
|
|
||||||
} from '@remixicon/react'
|
|
||||||
import {
|
|
||||||
useFocusToggle,
|
|
||||||
useTrackMutedIndicator,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
|
||||||
import { useFullScreen } from '../hooks/useFullScreen'
|
|
||||||
import { type Participant, Track } from 'livekit-client'
|
|
||||||
import { MuteAlertDialog } from './MuteAlertDialog'
|
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
|
||||||
|
|
||||||
const ZoomButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
|
||||||
trackRef,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isFullscreenAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('fullScreen')}
|
|
||||||
onPress={() => toggleFullScreen()}
|
|
||||||
>
|
|
||||||
<RiFullscreenLine />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FocusButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { mergedProps, inFocus } = useFocusToggle({
|
|
||||||
trackRef,
|
|
||||||
props: {},
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
onPress={(event) => mergedProps?.onClick?.(event as any)}
|
|
||||||
>
|
|
||||||
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const EffectsButton = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size={'sm'}
|
|
||||||
variant={'primaryTextDark'}
|
|
||||||
square
|
|
||||||
tooltip={t('effects')}
|
|
||||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
|
||||||
>
|
|
||||||
<RiImageCircleAiFill />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const MuteButton = ({ participant }: { participant: Participant }) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
|
|
||||||
const { isMuted } = useTrackMutedIndicator({
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
|
||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
|
||||||
trackRef,
|
|
||||||
hasKeyboardFocus,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
hasKeyboardFocus: boolean
|
|
||||||
}) => {
|
|
||||||
const [hovered, setHovered] = useState(false)
|
|
||||||
const [opacity, setOpacity] = useState(0)
|
|
||||||
|
|
||||||
const idleTimerRef = useRef<number | null>(null)
|
|
||||||
const [isIdleRef, setIsIdleRef] = useState(false)
|
|
||||||
|
|
||||||
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVisible) {
|
|
||||||
// Wait for next frame to ensure element is mounted
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setOpacity(0.6)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setOpacity(0)
|
|
||||||
}
|
|
||||||
}, [isVisible])
|
|
||||||
|
|
||||||
const handleMouseMove = () => {
|
|
||||||
if (idleTimerRef.current) {
|
|
||||||
window.clearTimeout(idleTimerRef.current)
|
|
||||||
}
|
|
||||||
idleTimerRef.current = window.setTimeout(() => {
|
|
||||||
setIsIdleRef(true)
|
|
||||||
}, MOUSE_IDLE_TIME)
|
|
||||||
setIsIdleRef(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const participant = trackRef.participant
|
|
||||||
|
|
||||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
|
||||||
const isLocal = trackRef.participant.isLocal
|
|
||||||
|
|
||||||
const canMute = useCanMute(participant)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
left: '0',
|
|
||||||
top: '0',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
})}
|
|
||||||
aria-hidden={!isVisible}
|
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
>
|
|
||||||
{isVisible && (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
backgroundColor: 'primaryDark.50',
|
|
||||||
transition: 'opacity 200ms linear',
|
|
||||||
zIndex: 1,
|
|
||||||
borderRadius: '0.25rem',
|
|
||||||
display: 'flex',
|
|
||||||
_hover: {
|
|
||||||
opacity: '0.95 !important',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
style={{ opacity }}
|
|
||||||
>
|
|
||||||
<HStack
|
|
||||||
gap={0.5}
|
|
||||||
className={css({
|
|
||||||
padding: '0.5rem',
|
|
||||||
_hover: {
|
|
||||||
opacity: '1 !important',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<FocusButton trackRef={trackRef} />
|
|
||||||
{!isScreenShare ? (
|
|
||||||
<>
|
|
||||||
{participant.isLocal ? (
|
|
||||||
<EffectsButton />
|
|
||||||
) : (
|
|
||||||
canMute && <MuteButton participant={participant} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
|
||||||
|
|
||||||
|
export const RoomMetadataSynchronizer = () => {
|
||||||
|
useSyncLiveKitMetadata()
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ import { text } from '@/primitives/Text'
|
|||||||
import { Button, Div } from '@/primitives'
|
import { Button, Div } from '@/primitives'
|
||||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
import { ParticipantsList } from '@/features/participants/components/ParticipantsList'
|
||||||
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
|
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||||
import React, { ReactNode, useCallback, useRef } from 'react'
|
import React, { ReactNode, useCallback, useRef } from 'react'
|
||||||
import { Chat } from '../prefabs/Chat'
|
import { Chat } from '@/features/chat/components/Chat'
|
||||||
import { Effects } from './effects/Effects'
|
import { Effects } from './effects/Effects'
|
||||||
import { Admin } from './Admin'
|
import { Admin } from './Admin'
|
||||||
import { Tools } from './Tools'
|
import { Tools } from './Tools'
|
||||||
@@ -218,7 +218,7 @@ export const SidePanel = () => {
|
|||||||
<Panel isOpen={isEffectsOpen}>
|
<Panel isOpen={isEffectsOpen}>
|
||||||
<Effects />
|
<Effects />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
<Panel isOpen={isChatOpen}>
|
||||||
<Chat />
|
<Chat />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||||
|
|||||||
+5
-4
@@ -11,16 +11,16 @@ import { useSnapshot } from 'valtio'
|
|||||||
import { userChoicesStore } from '@/stores/userChoices'
|
import { userChoicesStore } from '@/stores/userChoices'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This hook sets initial video quality for new participants as they join.
|
* Sets initial video quality for new participants as they join.
|
||||||
* LiveKit doesn't allow handling video quality preferences at the room level.
|
* LiveKit doesn't allow handling video quality preferences at the room level.
|
||||||
*/
|
*/
|
||||||
export const useVideoResolutionSubscription = () => {
|
export const VideoResolutionSubscription = () => {
|
||||||
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
|
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
|
||||||
|
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return
|
if (!room) return
|
||||||
|
|
||||||
const handleTrackPublished = (
|
const handleTrackPublished = (
|
||||||
publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
_participant: RemoteParticipant
|
_participant: RemoteParticipant
|
||||||
@@ -41,9 +41,10 @@ export const useVideoResolutionSubscription = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
}
|
}
|
||||||
}, [room, videoSubscribeQuality])
|
}, [room, videoSubscribeQuality])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { ReceivedChatMessage } from '@livekit/components-core'
|
|
||||||
import * as React from 'react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Text } from '@/primitives'
|
|
||||||
import type { MessageFormatter } from '@livekit/components-react'
|
|
||||||
|
|
||||||
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
|
|
||||||
entry: ReceivedChatMessage
|
|
||||||
hideMetadata?: boolean
|
|
||||||
messageFormatter?: MessageFormatter
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatEntry: (
|
|
||||||
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
|
|
||||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
ChatEntryProps
|
|
||||||
>(function ChatEntry(
|
|
||||||
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const formattedMessage = React.useMemo(() => {
|
|
||||||
return messageFormatter ? messageFormatter(entry.message) : entry.message
|
|
||||||
}, [entry.message, messageFormatter])
|
|
||||||
const time = new Date(entry.timestamp)
|
|
||||||
const locale = navigator ? navigator.language : 'en-US'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.25rem',
|
|
||||||
})}
|
|
||||||
ref={ref}
|
|
||||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
|
||||||
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{!hideMetadata && (
|
|
||||||
<span
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
paddingTop: '0.75rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Text bold={true} variant="sm">
|
|
||||||
{entry.from?.name ?? entry.from?.identity}
|
|
||||||
</Text>
|
|
||||||
<Text variant="sm" className={css({ color: 'gray.700' })}>
|
|
||||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
|
||||||
</Text>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Text
|
|
||||||
variant="sm"
|
|
||||||
margin={false}
|
|
||||||
className={css({
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
'& .lk-chat-link': {
|
|
||||||
color: 'blue',
|
|
||||||
textDecoration: 'underline',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{formattedMessage}
|
|
||||||
</Text>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { Button, TextArea } from '@/primitives'
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
|
||||||
import { useState, useEffect, RefObject } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
|
|
||||||
const MAX_ROWS = 6
|
|
||||||
|
|
||||||
interface ChatInputProps {
|
|
||||||
inputRef: RefObject<HTMLTextAreaElement>
|
|
||||||
onSubmit: (text: string) => void
|
|
||||||
isSending: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatInput = ({
|
|
||||||
inputRef,
|
|
||||||
onSubmit,
|
|
||||||
isSending,
|
|
||||||
}: ChatInputProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
const [rows, setRows] = useState(1)
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
onSubmit(text)
|
|
||||||
setText('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDisabled = !text.trim() || isSending
|
|
||||||
|
|
||||||
const submitOnEnter = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey)) return
|
|
||||||
e.preventDefault()
|
|
||||||
if (!isDisabled) handleSubmit()
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const resize = () => {
|
|
||||||
if (!inputRef.current) return
|
|
||||||
|
|
||||||
const textAreaLineHeight = 20 // Adjust this value based on your TextArea's line height
|
|
||||||
const previousRows = inputRef.current.rows
|
|
||||||
inputRef.current.rows = 1
|
|
||||||
|
|
||||||
const currentRows = Math.floor(
|
|
||||||
inputRef.current.scrollHeight / textAreaLineHeight
|
|
||||||
)
|
|
||||||
|
|
||||||
if (currentRows === previousRows) {
|
|
||||||
inputRef.current.rows = currentRows
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentRows >= MAX_ROWS) {
|
|
||||||
inputRef.current.rows = MAX_ROWS
|
|
||||||
inputRef.current.scrollTop = inputRef.current.scrollHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentRows < MAX_ROWS) {
|
|
||||||
inputRef.current.style.overflowY = 'hidden'
|
|
||||||
} else {
|
|
||||||
inputRef.current.style.overflowY = 'auto'
|
|
||||||
}
|
|
||||||
|
|
||||||
setRows(currentRows < MAX_ROWS ? currentRows : MAX_ROWS)
|
|
||||||
}
|
|
||||||
|
|
||||||
resize()
|
|
||||||
}, [text, inputRef])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HStack
|
|
||||||
className={css({
|
|
||||||
margin: '0.75rem 0 1.5rem',
|
|
||||||
padding: '0.5rem',
|
|
||||||
backgroundColor: 'gray.100',
|
|
||||||
borderRadius: 4,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
ref={inputRef}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
submitOnEnter(e)
|
|
||||||
}}
|
|
||||||
onKeyUp={(e) => e.stopPropagation()}
|
|
||||||
placeholder={t('textArea.placeholder')}
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => {
|
|
||||||
setText(e.target.value)
|
|
||||||
}}
|
|
||||||
rows={rows || 1}
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
resize: 'none',
|
|
||||||
height: 'auto',
|
|
||||||
minHeight: `34px`,
|
|
||||||
lineHeight: 1.25,
|
|
||||||
padding: '7px 10px',
|
|
||||||
overflowY: 'hidden',
|
|
||||||
}}
|
|
||||||
placeholderStyle={'strong'}
|
|
||||||
spellCheck={false}
|
|
||||||
maxLength={2000}
|
|
||||||
aria-label={t('textArea.label')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
square
|
|
||||||
invisible
|
|
||||||
variant="tertiaryText"
|
|
||||||
size="sm"
|
|
||||||
onPress={handleSubmit}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={t('button.label')}
|
|
||||||
>
|
|
||||||
<RiSendPlane2Fill />
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+1
-2
@@ -2,7 +2,7 @@ import { Button } from '@/primitives'
|
|||||||
import { RiSettings3Line } from '@remixicon/react'
|
import { RiSettings3Line } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
import { openSettingsDialog } from '@/stores/settings'
|
||||||
|
|
||||||
export const SettingsButton = ({
|
export const SettingsButton = ({
|
||||||
settingTab,
|
settingTab,
|
||||||
@@ -12,7 +12,6 @@ export const SettingsButton = ({
|
|||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
const { openSettingsDialog } = useSettingsDialog()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import useLongPress from '@/features/shortcuts/useLongPress'
|
|||||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||||
import {
|
import {
|
||||||
useIsSpeaking,
|
useIsSpeaking,
|
||||||
useLocalParticipant,
|
|
||||||
useMaybeRoomContext,
|
useMaybeRoomContext,
|
||||||
|
useRoomContext,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||||
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
@@ -169,7 +169,7 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ActiveSpeakerWrapper = () => {
|
const ActiveSpeakerWrapper = () => {
|
||||||
const { localParticipant } = useLocalParticipant()
|
const room = useRoomContext()
|
||||||
const isSpeaking = useIsSpeaking(localParticipant)
|
const isSpeaking = useIsSpeaking(room.localParticipant)
|
||||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { RiBookOpenLine } from '@remixicon/react'
|
||||||
|
import { MenuItem } from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
|
export const DocumentationMenuItem = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
|
const { data } = useConfig()
|
||||||
|
|
||||||
|
if (!data?.documentation_url) return
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
href={data.documentation_url}
|
||||||
|
target="_blank"
|
||||||
|
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||||
|
>
|
||||||
|
<RiBookOpenLine size={20} />
|
||||||
|
{t('documentation')}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
+2
@@ -5,6 +5,7 @@ import { SettingsMenuItem } from './SettingsMenuItem'
|
|||||||
import { FeedbackMenuItem } from './FeedbackMenuItem'
|
import { FeedbackMenuItem } from './FeedbackMenuItem'
|
||||||
import { EffectsMenuItem } from './EffectsMenuItem'
|
import { EffectsMenuItem } from './EffectsMenuItem'
|
||||||
import { SupportMenuItem } from './SupportMenuItem'
|
import { SupportMenuItem } from './SupportMenuItem'
|
||||||
|
import { DocumentationMenuItem } from './DocumentationMenuItem'
|
||||||
import { TranscriptMenuItem } from './TranscriptMenuItem'
|
import { TranscriptMenuItem } from './TranscriptMenuItem'
|
||||||
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
|
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
|
||||||
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
|
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
|
||||||
@@ -28,6 +29,7 @@ export const OptionsMenuItems = () => {
|
|||||||
<Separator />
|
<Separator />
|
||||||
<MenuSection>
|
<MenuSection>
|
||||||
<SupportMenuItem />
|
<SupportMenuItem />
|
||||||
|
<DocumentationMenuItem />
|
||||||
<FeedbackMenuItem />
|
<FeedbackMenuItem />
|
||||||
<SettingsMenuItem />
|
<SettingsMenuItem />
|
||||||
</MenuSection>
|
</MenuSection>
|
||||||
|
|||||||
+1
-2
@@ -2,11 +2,10 @@ import { RiSettings3Line } from '@remixicon/react'
|
|||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
import { openSettingsDialog } from '@/stores/settings'
|
||||||
|
|
||||||
export const SettingsMenuItem = () => {
|
export const SettingsMenuItem = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
const { openSettingsDialog } = useSettingsDialog()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|||||||
-110
@@ -1,110 +0,0 @@
|
|||||||
import { ReactNode, useState } from 'react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { ToggleButton } from 'react-aria-components'
|
|
||||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
|
||||||
import { RiArrowUpSLine } from '@remixicon/react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const ToggleHeader = styled(ToggleButton, {
|
|
||||||
base: {
|
|
||||||
minHeight: '40px', //fixme hardcoded value
|
|
||||||
paddingRight: '.5rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
transition: 'background 200ms',
|
|
||||||
borderTopRadius: '7px',
|
|
||||||
'&[data-hovered]': {
|
|
||||||
backgroundColor: 'greyscale.50',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Container = styled('div', {
|
|
||||||
base: {
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'greyscale.250',
|
|
||||||
borderRadius: '8px',
|
|
||||||
margin: '0 .625rem',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ListContainer = styled(VStack, {
|
|
||||||
base: {
|
|
||||||
borderTop: '1px solid',
|
|
||||||
borderTopColor: 'greyscale.250',
|
|
||||||
alignItems: 'start',
|
|
||||||
overflowY: 'scroll',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
minHeight: 0,
|
|
||||||
flexGrow: 1,
|
|
||||||
display: 'flex',
|
|
||||||
paddingY: '0.5rem',
|
|
||||||
paddingX: '1rem',
|
|
||||||
gap: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ParticipantsCollapsableListProps<T> = {
|
|
||||||
heading: string
|
|
||||||
participants: Array<T>
|
|
||||||
renderParticipant: (participant: T) => JSX.Element
|
|
||||||
action?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ParticipantsCollapsableList<T>({
|
|
||||||
heading,
|
|
||||||
participants,
|
|
||||||
renderParticipant,
|
|
||||||
action,
|
|
||||||
}: ParticipantsCollapsableListProps<T>) {
|
|
||||||
const { t } = useTranslation('rooms')
|
|
||||||
const [isOpen, setIsOpen] = useState(true)
|
|
||||||
const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, {
|
|
||||||
name: heading,
|
|
||||||
})
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<ToggleHeader
|
|
||||||
isSelected={isOpen}
|
|
||||||
aria-label={label}
|
|
||||||
onPress={() => setIsOpen(!isOpen)}
|
|
||||||
style={{
|
|
||||||
borderRadius: !isOpen ? '7px' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HStack
|
|
||||||
justify="space-between"
|
|
||||||
className={css({
|
|
||||||
margin: '0 1.25rem',
|
|
||||||
width: '100%',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
fontSize: '1rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{heading}
|
|
||||||
</div>
|
|
||||||
<div>{participants?.length || 0}</div>
|
|
||||||
</HStack>
|
|
||||||
<RiArrowUpSLine
|
|
||||||
size={32}
|
|
||||||
style={{
|
|
||||||
transform: isOpen ? 'rotate(-180deg)' : undefined,
|
|
||||||
transition: 'transform 200ms',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ToggleHeader>
|
|
||||||
{isOpen && (
|
|
||||||
<ListContainer>
|
|
||||||
{action}
|
|
||||||
{participants.map((participant) => renderParticipant(participant))}
|
|
||||||
</ListContainer>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-112
@@ -1,112 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useParticipants } from '@livekit/components-react'
|
|
||||||
|
|
||||||
import { Div, H } from '@/primitives'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { ParticipantListItem } from '../../controls/Participants/ParticipantListItem'
|
|
||||||
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
|
|
||||||
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
|
|
||||||
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
|
|
||||||
import { WaitingParticipantListItem } from './WaitingParticipantListItem'
|
|
||||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
|
||||||
import type { Participant } from 'livekit-client'
|
|
||||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
|
||||||
import { MuteEveryoneButton } from './MuteEveryoneButton'
|
|
||||||
|
|
||||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
|
||||||
export const ParticipantsList = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
|
||||||
|
|
||||||
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
|
|
||||||
// because the 'useLocalParticipant' hook does not update the participant's information when their
|
|
||||||
// metadata/name changes. The LiveKit team has marked this as a TODO item in the code.
|
|
||||||
const participants = useParticipants()
|
|
||||||
|
|
||||||
const sortedRemoteParticipants = participants
|
|
||||||
.slice(1)
|
|
||||||
.sort((participantA, participantB) => {
|
|
||||||
const nameA = participantA.name || participantA.identity
|
|
||||||
const nameB = participantB.name || participantB.identity
|
|
||||||
return nameA.localeCompare(nameB)
|
|
||||||
})
|
|
||||||
|
|
||||||
const sortedParticipants = [
|
|
||||||
participants[0], // first participant returned by the hook, is always the local one
|
|
||||||
...sortedRemoteParticipants,
|
|
||||||
]
|
|
||||||
|
|
||||||
const raisedHandParticipants = participants
|
|
||||||
.filter((participant) => !!participant.attributes.handRaisedAt)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const dateA = new Date(a.attributes.handRaisedAt)
|
|
||||||
const dateB = new Date(b.attributes.handRaisedAt)
|
|
||||||
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
|
|
||||||
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
|
|
||||||
return timeA - timeB
|
|
||||||
})
|
|
||||||
|
|
||||||
const { waitingParticipants, handleParticipantEntry } =
|
|
||||||
useWaitingParticipants()
|
|
||||||
|
|
||||||
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
|
||||||
return (
|
|
||||||
<Div overflowY="scroll">
|
|
||||||
<H
|
|
||||||
lvl={2}
|
|
||||||
className={css({
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: 'greyscale.600',
|
|
||||||
padding: '0 1.5rem',
|
|
||||||
marginBottom: '0.83em',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('subheading').toUpperCase()}
|
|
||||||
</H>
|
|
||||||
{waitingParticipants?.length > 0 && (
|
|
||||||
<Div marginBottom=".9375rem">
|
|
||||||
<ParticipantsCollapsableList<WaitingParticipant>
|
|
||||||
heading={t('waiting.title')}
|
|
||||||
participants={waitingParticipants}
|
|
||||||
renderParticipant={(participant) => (
|
|
||||||
<WaitingParticipantListItem
|
|
||||||
key={participant.id}
|
|
||||||
participant={participant}
|
|
||||||
onAction={handleParticipantEntry}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
action={<></>}
|
|
||||||
/>
|
|
||||||
</Div>
|
|
||||||
)}
|
|
||||||
{raisedHandParticipants.length > 0 && (
|
|
||||||
<Div marginBottom=".9375rem">
|
|
||||||
<ParticipantsCollapsableList<Participant>
|
|
||||||
heading={t('raisedHands')}
|
|
||||||
participants={raisedHandParticipants}
|
|
||||||
renderParticipant={(participant) => (
|
|
||||||
<HandRaisedListItem
|
|
||||||
key={participant.identity}
|
|
||||||
participant={participant}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
action={
|
|
||||||
<LowerAllHandsButton participants={raisedHandParticipants} />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Div>
|
|
||||||
)}
|
|
||||||
<ParticipantsCollapsableList<Participant>
|
|
||||||
heading={t('contributors')}
|
|
||||||
participants={sortedParticipants}
|
|
||||||
renderParticipant={(participant) => (
|
|
||||||
<ParticipantListItem
|
|
||||||
key={participant.identity}
|
|
||||||
participant={participant}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
action={<MuteEveryoneButton participants={sortedRemoteParticipants} />}
|
|
||||||
/>
|
|
||||||
</Div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-90
@@ -1,90 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
|
||||||
import { ToggleButton } from '@/primitives'
|
|
||||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useParticipants } from '@livekit/components-react'
|
|
||||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
|
||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
|
||||||
|
|
||||||
export const ParticipantsToggle = ({
|
|
||||||
onPress,
|
|
||||||
...props
|
|
||||||
}: ToggleButtonProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Context could not be used due to inconsistent refresh behavior.
|
|
||||||
* The 'numParticipant' property on the room only updates when the room's metadata changes,
|
|
||||||
* resulting in a delay compared to the participant list's actual refresh rate.
|
|
||||||
*/
|
|
||||||
const participants = useParticipants()
|
|
||||||
const numParticipants = participants?.length
|
|
||||||
const announcedCount =
|
|
||||||
numParticipants && numParticipants > 0 ? numParticipants : 1
|
|
||||||
|
|
||||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
|
||||||
|
|
||||||
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
|
||||||
id: 'toggle-participants',
|
|
||||||
handler: toggleParticipants,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'relative',
|
|
||||||
display: 'inline-block',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
|
|
||||||
<ToggleButton
|
|
||||||
square
|
|
||||||
variant="primaryTextDark"
|
|
||||||
aria-label={`${t(tooltipLabel)}. ${t('count', {
|
|
||||||
count: announcedCount,
|
|
||||||
})}.`}
|
|
||||||
isSelected={isParticipantsOpen}
|
|
||||||
aria-expanded={isParticipantsOpen}
|
|
||||||
onPress={(e) => {
|
|
||||||
toggleParticipants()
|
|
||||||
onPress?.(e)
|
|
||||||
}}
|
|
||||||
data-attr={`controls-participants-${tooltipLabel}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<RiGroupLine />
|
|
||||||
</ToggleButton>
|
|
||||||
</VisualOnlyTooltip>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-.25rem',
|
|
||||||
right: '-.25rem',
|
|
||||||
width: '1.25rem',
|
|
||||||
height: '1.25rem',
|
|
||||||
backgroundColor: 'gray',
|
|
||||||
borderRadius: '50%',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
textAlign: 'center',
|
|
||||||
zIndex: 1,
|
|
||||||
userSelect: 'none',
|
|
||||||
})}
|
|
||||||
aria-hidden={true}
|
|
||||||
>
|
|
||||||
{numParticipants < 100 ? (
|
|
||||||
numParticipants || 1
|
|
||||||
) : (
|
|
||||||
<RiInfinityLine size={10} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useFocusToggleParticipant } from '@/features/rooms/livekit/hooks/useFocusToggleParticipant'
|
|
||||||
import type { Participant } from 'livekit-client'
|
|
||||||
import { RiPushpin2Fill } from '@remixicon/react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
|
|
||||||
export const PinBadge = ({ participant }: { participant: Participant }) => {
|
|
||||||
const { inFocus } = useFocusToggleParticipant(participant)
|
|
||||||
|
|
||||||
if (!inFocus) return
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
height: '18px',
|
|
||||||
width: '18px',
|
|
||||||
borderRadius: '100%',
|
|
||||||
background: 'white',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '-2px',
|
|
||||||
right: '-4px',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<RiPushpin2Fill size={14} aria-hidden />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useId } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiGroupLine } from '@remixicon/react'
|
||||||
|
import { ToggleButton, type ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import { ParticipantsCount } from '@/features/participants/components/ParticipantsCount'
|
||||||
|
|
||||||
|
const containerStyles = css({ position: 'relative', display: 'inline-block' })
|
||||||
|
|
||||||
|
export const ParticipantsToggle = ({
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}: ToggleButtonProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
|
||||||
|
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||||
|
const countId = useId()
|
||||||
|
|
||||||
|
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'toggle-participants',
|
||||||
|
handler: toggleParticipants,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePress = useCallback(
|
||||||
|
(e: Parameters<NonNullable<ToggleButtonProps['onPress']>>[0]) => {
|
||||||
|
toggleParticipants()
|
||||||
|
onPress?.(e)
|
||||||
|
},
|
||||||
|
[toggleParticipants, onPress]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerStyles}>
|
||||||
|
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
|
||||||
|
<ToggleButton
|
||||||
|
square
|
||||||
|
variant="primaryTextDark"
|
||||||
|
aria-label={t(tooltipLabel)}
|
||||||
|
aria-describedby={countId}
|
||||||
|
isSelected={isParticipantsOpen}
|
||||||
|
aria-expanded={isParticipantsOpen}
|
||||||
|
onPress={handlePress}
|
||||||
|
data-attr={`controls-participants-${tooltipLabel}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RiGroupLine />
|
||||||
|
</ToggleButton>
|
||||||
|
</VisualOnlyTooltip>
|
||||||
|
<ParticipantsCount describedById={countId} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
import type { TrackSource } from '@livekit/protocol'
|
import type { TrackSource } from '@livekit/protocol'
|
||||||
import {
|
import { useLocalParticipantPermissions } from '@livekit/components-react'
|
||||||
useLocalParticipant,
|
|
||||||
useParticipantPermissions,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
|
|
||||||
export function useCanPublishTrack(trackSource: TrackSource): boolean {
|
export function useCanPublishTrack(trackSource: TrackSource): boolean {
|
||||||
const { localParticipant } = useLocalParticipant()
|
const permissions = useLocalParticipantPermissions()
|
||||||
const permissions = useParticipantPermissions({
|
|
||||||
participant: localParticipant,
|
|
||||||
})
|
|
||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
permissions?.canPublish &&
|
permissions?.canPublish &&
|
||||||
permissions?.canPublishSources?.includes(trackSource)
|
permissions?.canPublishSources?.includes(trackSource)
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
|
||||||
import { FeatureFlags } from '@/features/analytics/enums.ts'
|
|
||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled.ts'
|
|
||||||
import { isMobileBrowser } from '@livekit/components-core'
|
|
||||||
|
|
||||||
export const useIsAdvancedConnectionObserverEnabled = () => {
|
|
||||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
|
||||||
|
|
||||||
const isMobile = isMobileBrowser()
|
|
||||||
return !isMobile && isAnalyticsEnabled && featureEnabled
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Track } from 'livekit-client'
|
import { RoomEvent, Track } from 'livekit-client'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
@@ -43,7 +43,9 @@ export const usePublishSourcesManager = () => {
|
|||||||
const roomId = data?.slug
|
const roomId = data?.slug
|
||||||
|
|
||||||
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
|
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
|
||||||
const remoteParticipants = useRemoteParticipants()
|
const remoteParticipants = useRemoteParticipants({
|
||||||
|
updateOnlyOn: [RoomEvent.ParticipantAttributesChanged],
|
||||||
|
})
|
||||||
|
|
||||||
const unprivilegedRemoteParticipants = remoteParticipants.filter(
|
const unprivilegedRemoteParticipants = remoteParticipants.filter(
|
||||||
(participant) => !getParticipantIsRoomAdmin(participant)
|
(participant) => !getParticipantIsRoomAdmin(participant)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
import { Participant, RoomEvent } from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
|
useRoomContext,
|
||||||
useParticipantAttribute,
|
useParticipantAttribute,
|
||||||
useParticipants,
|
useParticipantInfo,
|
||||||
|
useRemoteParticipants,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { isLocal } from '@/utils/livekit'
|
import { isLocal } from '@/utils/livekit'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
@@ -12,28 +14,55 @@ type useRaisedHandProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
|
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
|
||||||
const { isHandRaised } = useRaisedHand({ participant })
|
const room = useRoomContext()
|
||||||
|
const { identity } = useParticipantInfo({ participant })
|
||||||
|
const localIdentity = room.localParticipant.identity
|
||||||
|
|
||||||
const participants = useParticipants()
|
const localHandRaisedAt = useParticipantAttribute('handRaisedAt', {
|
||||||
|
participant: room.localParticipant,
|
||||||
|
})
|
||||||
|
|
||||||
|
const remoteParticipants = useRemoteParticipants({
|
||||||
|
updateOnlyOn: [RoomEvent.ParticipantAttributesChanged],
|
||||||
|
})
|
||||||
|
|
||||||
|
const raisedHands = useMemo(() => {
|
||||||
|
const byIdentity = new Map<string, number>()
|
||||||
|
|
||||||
|
const add = (id: string, value?: string) => {
|
||||||
|
const time = new Date(value ?? '').getTime()
|
||||||
|
if (Number.isNaN(time)) return
|
||||||
|
const existing = byIdentity.get(id)
|
||||||
|
if (existing === undefined || time < existing) byIdentity.set(id, time)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localIdentity) add(localIdentity, localHandRaisedAt)
|
||||||
|
|
||||||
|
remoteParticipants.forEach((p) =>
|
||||||
|
add(p.identity, p.attributes.handRaisedAt)
|
||||||
|
)
|
||||||
|
|
||||||
|
return byIdentity
|
||||||
|
}, [remoteParticipants, localIdentity, localHandRaisedAt])
|
||||||
|
|
||||||
|
const sortedHands = useMemo(
|
||||||
|
() =>
|
||||||
|
[...raisedHands.entries()]
|
||||||
|
.map(([identity, time]) => ({ identity, time }))
|
||||||
|
.sort(
|
||||||
|
(a, b) => a.time - b.time || a.identity.localeCompare(b.identity)
|
||||||
|
),
|
||||||
|
[raisedHands]
|
||||||
|
)
|
||||||
|
|
||||||
const positionInQueue = useMemo(() => {
|
const positionInQueue = useMemo(() => {
|
||||||
if (!isHandRaised) return
|
const index = sortedHands.findIndex((h) => h.identity === identity)
|
||||||
|
return index === -1 ? undefined : index + 1
|
||||||
return (
|
}, [sortedHands, identity])
|
||||||
participants
|
|
||||||
.filter((p) => !!p.attributes.handRaisedAt)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const dateA = new Date(a.attributes.handRaisedAt)
|
|
||||||
const dateB = new Date(b.attributes.handRaisedAt)
|
|
||||||
return dateA.getTime() - dateB.getTime()
|
|
||||||
})
|
|
||||||
.findIndex((p) => p.identity === participant.identity) + 1
|
|
||||||
)
|
|
||||||
}, [participants, participant, isHandRaised])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
positionInQueue,
|
positionInQueue,
|
||||||
firstInQueue: positionInQueue == 1,
|
firstInQueue: positionInQueue === 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
|
|
||||||
import React from 'react'
|
|
||||||
import {
|
|
||||||
formatChatMessageLinks,
|
|
||||||
useChat,
|
|
||||||
useParticipants,
|
|
||||||
useRoomContext,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { chatStore } from '@/stores/chat'
|
|
||||||
import { Div, Text } from '@/primitives'
|
|
||||||
import { ChatInput } from '../components/chat/Input'
|
|
||||||
import { ChatEntry } from '../components/chat/Entry'
|
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
|
||||||
import {
|
|
||||||
type LocalParticipant,
|
|
||||||
type RemoteParticipant,
|
|
||||||
RoomEvent,
|
|
||||||
} from 'livekit-client'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
|
||||||
|
|
||||||
export interface ChatProps
|
|
||||||
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
|
|
||||||
* in the room. Only users who are in the room at the time of dispatch will receive the message.
|
|
||||||
*/
|
|
||||||
export function Chat({ ...props }: ChatProps) {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
|
||||||
|
|
||||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
|
||||||
const ulRef = React.useRef<HTMLUListElement>(null)
|
|
||||||
|
|
||||||
const room = useRoomContext()
|
|
||||||
const { send, chatMessages, isSending } = useChat()
|
|
||||||
|
|
||||||
const { isChatOpen, isSidePanelOpen } = useSidePanel()
|
|
||||||
const chatSnap = useSnapshot(chatStore)
|
|
||||||
|
|
||||||
// Keep track of the element that opened the chat so we can restore focus
|
|
||||||
// when the chat panel is closed.
|
|
||||||
useRestoreFocus(isChatOpen, {
|
|
||||||
// Avoid layout "jump" during the side panel slide-in animation.
|
|
||||||
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
|
||||||
onOpened: () => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
inputRef.current?.focus({ preventScroll: true })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
preventScroll: true,
|
|
||||||
shouldRestoreOnClose: () => !isSidePanelOpen,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
|
||||||
const participants = useParticipants()
|
|
||||||
|
|
||||||
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
|
||||||
const previousMessageCount = React.useRef(0)
|
|
||||||
|
|
||||||
async function handleSubmit(text: string) {
|
|
||||||
if (!send || !text) return
|
|
||||||
await send(text)
|
|
||||||
inputRef?.current?.focus({ preventScroll: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
|
||||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
|
||||||
// (see https://github.com/livekit/components-js/issues/1158)
|
|
||||||
// Remove this once we refactor chat to use the new text stream approach
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!chatMessages || chatMessages.length <= previousMessageCount.current)
|
|
||||||
return
|
|
||||||
const msg = chatMessages.slice(-1)[0]
|
|
||||||
const from = msg.from as RemoteParticipant | LocalParticipant | undefined
|
|
||||||
room.emit(RoomEvent.ChatMessage, msg, from)
|
|
||||||
previousMessageCount.current = chatMessages.length
|
|
||||||
}, [chatMessages, room])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (chatMessages.length > 0 && ulRef.current) {
|
|
||||||
ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight })
|
|
||||||
}
|
|
||||||
}, [ulRef, chatMessages])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (chatMessages.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
isChatOpen &&
|
|
||||||
lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp
|
|
||||||
) {
|
|
||||||
lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp
|
|
||||||
chatStore.unreadMessages = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const unreadMessageCount = chatMessages.filter(
|
|
||||||
(msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current
|
|
||||||
).length
|
|
||||||
|
|
||||||
if (
|
|
||||||
unreadMessageCount > 0 &&
|
|
||||||
chatSnap.unreadMessages !== unreadMessageCount
|
|
||||||
) {
|
|
||||||
chatStore.unreadMessages = unreadMessageCount
|
|
||||||
}
|
|
||||||
}, [chatMessages, chatSnap.unreadMessages, isChatOpen])
|
|
||||||
|
|
||||||
const renderedMessages = React.useMemo(() => {
|
|
||||||
return chatMessages.map((msg, idx, allMsg) => {
|
|
||||||
const hideMetadata =
|
|
||||||
idx >= 1 &&
|
|
||||||
msg.timestamp - allMsg[idx - 1].timestamp < 60_000 &&
|
|
||||||
allMsg[idx - 1].from === msg.from
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChatEntry
|
|
||||||
key={msg.id ?? idx}
|
|
||||||
hideMetadata={hideMetadata}
|
|
||||||
entry={msg}
|
|
||||||
messageFormatter={formatChatMessageLinks}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
// This ensures that the chat message list is updated to reflect any changes in participant information.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [chatMessages, participants])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Div
|
|
||||||
display={'flex'}
|
|
||||||
padding={'0 1.5rem'}
|
|
||||||
flexGrow={1}
|
|
||||||
flexDirection={'column'}
|
|
||||||
minHeight={0}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
variant="sm"
|
|
||||||
className={css({
|
|
||||||
padding: '0.75rem',
|
|
||||||
backgroundColor: 'greyscale.50',
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: '0.75rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('disclaimer')}
|
|
||||||
</Text>
|
|
||||||
<Div
|
|
||||||
flexGrow={1}
|
|
||||||
flexDirection={'column'}
|
|
||||||
minHeight={0}
|
|
||||||
overflowY="scroll"
|
|
||||||
>
|
|
||||||
<ul
|
|
||||||
className="lk-list lk-chat-messages"
|
|
||||||
ref={ulRef}
|
|
||||||
role="log"
|
|
||||||
aria-relevant="additions"
|
|
||||||
>
|
|
||||||
{renderedMessages}
|
|
||||||
</ul>
|
|
||||||
</Div>
|
|
||||||
<ChatInput
|
|
||||||
inputRef={inputRef}
|
|
||||||
onSubmit={(e) => handleSubmit(e)}
|
|
||||||
isSending={isSending}
|
|
||||||
/>
|
|
||||||
</Div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user