Compare commits

..

17 Commits

Author SHA1 Message Date
lebaudantoine 51f1f0ebbf 🔖(minor) bump release to 0.1.20
Misc fixes.
2025-04-28 23:13:31 +02:00
lebaudantoine 9e27d0f345 🚑️(frontend) throttle emoji reaction sending to prevent DoS attacks
Apply rate limiting to emoji reactions after discovering malicious users using
auto-clickers to flood the system and crash other participants' apps.
2025-04-28 20:08:47 +02:00
lebaudantoine 978d931bd7 (frontend) create hook for rate-limiting functions that could spam UI
Implement custom hook to throttle JavaScript function calls and prevent
interface performance degradation.
2025-04-28 20:08:47 +02:00
lebaudantoine 0c811222d4 ♻️(frontend) change Panel keepAlive default to false for all side panels
Reverse default behavior for Panel component to unmount content from DOM when
closed instead of keeping it alive. Makes DOM updates more lightweight by
removing unused panel content. Improves performance particularly in complex
room with hundred of participants.

Exception made for chat panel which retains keepAlive=true to preserve
unsent messages that users may want to submit later.
2025-04-28 18:04:26 +02:00
lebaudantoine 94171dcb82 (frontend) add keepAlive option to Panel component
Implement new keepAlive property for Panel component to control DOM retention
when panel is closed. When false, panel content is unmounted from DOM on
close, resetting scroll position and input states. Provides finer control
over panel behavior and memory management.
2025-04-28 18:04:26 +02:00
lebaudantoine 56c1cd98fa 🔧(frontend) make feedback form configurable via backend settings
Implement conditional rendering that hides all feedback-related UI components
when feedback is disabled in backend configuration.

Also, feedback URL is now customizable.
2025-04-28 17:37:31 +02:00
lebaudantoine f2e6edb90d 🚩(frontend) disable meeting rating when analytics is not configured
Hide the rating module in the feedback route when analytics service is
unavailable to self host La Suite Meet without analytics.
2025-04-28 17:37:31 +02:00
lebaudantoine bc76c44fe9 ♻️(frontend) refactor ParticipantName component for internationalization
Component now supports i18n. The participant tile needs further refactoring as
it still mixes LiveKit CSS with custom styling.
2025-04-28 16:49:06 +02:00
lebaudantoine e519f00342 🔥(frontend) remove duplicated aria-label from join screen username input
Eliminate redundant accessibility attribute that wasn't providing relevant
information to screen readers.
2025-04-28 16:48:52 +02:00
lebaudantoine 2246bb7782 ️(frontend) lower silent login retry interval from 5 min to 30 sec
This change enhances user experience by making automatic login more responsive.
Only occurs on app navigation/refresh, not internal navigation.

Testing in production, can revert if needed. Will later refactor to use
backend configuration.
2025-04-28 13:21:17 +02:00
lebaudantoine e210f26f9c 🐛(frontend) prevent silent login in webmail iframe integration
Fix calendar integration by preventing silent login triggers within webmail
iframes. Refactor code to only initialize app components when not in SDK
context. Resolves unexpected behavior in Firefox where iframes were
incorrectly rendering the homepage instead of intended calendar content.
2025-04-28 13:21:17 +02:00
lebaudantoine 888dfe76c7 🐛(backend) resolve backchannel calls to LiveKit in docker-compose
Fix container networking issue where app-dev container couldn't resolve
localhost address when calling LiveKit API. Update configuration to use
proper container network addressing for backchannel communication between
services.
2025-04-25 12:52:14 +02:00
lebaudantoine ae17fbdaa8 ♻️(backend) extract livekit API client creation to reusable utility
Create dedicated utility function for livekit API client initialization.
Centralizes configuration logic including custom session handling for SSL
verification. Improves code reuse across backend components that interact
with LiveKit.
2025-04-24 18:05:52 +02:00
lebaudantoine 2ef95aa835 ♻️(backend) update BaseEgress to use custom session from livekit-api
Refactor BaseEgress class to leverage latest livekit-api client's custom
session support. Simplifies code by using built-in capability to disable SSL
verification in development environments instead of previous workaround.
2025-04-24 18:05:52 +02:00
lebaudantoine a83e5c4b1c 🔥(backend) delete overly complex BaseEgress tests
Remove BaseEgress tests that were overly complicated and had excessive
mocking, making them unrealistic and difficult to maintain. Will replace with
more straightforward tests in future commits that better reflect actual code
behavior.
2025-04-24 18:05:52 +02:00
lebaudantoine 9cc79ba159 🩹(backend) correct typo in WorkerConfig parameter name
Fix minor spelling error in WorkerConfig parameter that had no functional
impact but improves code clarity and consistency.
2025-04-24 18:05:52 +02:00
lebaudantoine c63adf9c8c ⬆️(backend) upgrade livekit-api to latest version
Update livekit-api dependency to most recent release, enabling custom session
configuration. New version allows disabling SSL verification in local
development environment through session parameter support.
2025-04-24 18:05:52 +02:00
45 changed files with 324 additions and 225 deletions
+2
View File
@@ -72,6 +72,8 @@ services:
- nginx
- livekit
- createbuckets
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
celery-dev:
user: ${DOCKER_USER:-1000}
-1
View File
@@ -308,7 +308,6 @@ These are the environmental options available on meet backend.
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | record meeting option | false |
| RECORDING_OUTPUT_FOLDER | folder to store meetings | recordings |
| RECORDING_VERIFY_SSL | verify ssl for recording storage | true |
| RECORDING_WORKER_CLASSES | worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | enable storage event authorization | true |
+2 -1
View File
@@ -48,7 +48,8 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Livekit Token settings
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://localhost:7880
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
LIVEKIT_VERIFY_SSL=False
ALLOW_UNREGISTERED_ROOMS=False
# Recording
@@ -17,7 +17,6 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
verify_ssl: Optional[bool]
bucket_args: Optional[dict]
@classmethod
@@ -29,7 +28,6 @@ class WorkerServiceConfig:
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION,
verify_ssl=settings.RECORDING_VERIFY_SSL,
bucket_args={
"endpoint": settings.AWS_S3_ENDPOINT_URL,
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
+14 -14
View File
@@ -2,11 +2,10 @@
# pylint: disable=no-member
import aiohttp
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from livekit.api.egress_service import EgressService
from ... import utils
from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
@@ -29,21 +28,22 @@ class BaseEgressService:
async def _handle_request(self, request, method_name: str):
"""Handle making a request to the LiveKit API and returns the response."""
# Use HTTP connector for local development with Tilt,
# where cluster communications are unsecure
connector = aiohttp.TCPConnector(ssl=self._config.verify_ssl)
lkapi = utils.create_livekit_client(self._config.server_configurations)
async with aiohttp.ClientSession(connector=connector) as session:
client = EgressService(session, **self._config.server_configurations)
method = getattr(client, method_name)
try:
response = await method(request)
except livekit_api.TwirpError as e:
raise WorkerConnectionError(
f"LiveKit client connection error, {e.message}."
) from e
# ruff: noqa: SLF001
# pylint: disable=protected-access
method = getattr(lkapi._egress, method_name)
try:
response = await method(request)
return response
except livekit_api.TwirpError as e:
raise WorkerConnectionError(
f"LiveKit client connection error, {e.message}."
) from e
finally:
await lkapi.aclose()
def stop(self, worker_id: str) -> str:
"""Stop an ongoing egress worker.
+1 -2
View File
@@ -14,7 +14,6 @@ from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
LiveKitAPI,
SendDataRequest,
TwirpError,
)
@@ -347,7 +346,7 @@ class LobbyService:
"type": settings.LOBBY_NOTIFICATION_TYPE,
}
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
lkapi = utils.create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
@@ -32,7 +32,6 @@ def test_settings():
mocked_settings = {
"RECORDING_OUTPUT_FOLDER": "/test/output",
"LIVEKIT_CONFIGURATION": {"server": "test.example.com"},
"RECORDING_VERIFY_SSL": True,
"AWS_S3_ENDPOINT_URL": "https://s3.test.com",
"AWS_S3_ACCESS_KEY_ID": "test_key",
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
@@ -56,7 +55,6 @@ def test_config_initialization(default_config):
"""Test that WorkerServiceConfig is properly initialized from settings"""
assert default_config.output_folder == "/test/output"
assert default_config.server_configurations == {"server": "test.example.com"}
assert default_config.verify_ssl is True
assert default_config.bucket_args == {
"endpoint": "https://s3.test.com",
"access_key": "test_key",
@@ -76,7 +74,6 @@ def test_config_immutability(default_config):
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
RECORDING_VERIFY_SSL=True,
AWS_S3_ENDPOINT_URL="https://s3.test.com",
AWS_S3_ACCESS_KEY_ID="test_key",
AWS_S3_SECRET_ACCESS_KEY="test_secret",
@@ -6,10 +6,9 @@ Test worker service classes.
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
import pytest
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
from core.recording.worker.exceptions import WorkerResponseError
from core.recording.worker.factories import WorkerServiceConfig
from core.recording.worker.services import (
AudioCompositeEgressService,
@@ -25,11 +24,10 @@ def config():
return WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"host": "test.livekit.io",
"url": "test.livekit.io",
"api_key": "test_key",
"api_secret": "test_secret",
},
verify_ssl=True,
bucket_args={
"endpoint": "https://s3.test.com",
"access_key": "test_key",
@@ -127,58 +125,6 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
assert result.endswith(f"{filename}.{extension}")
def test_base_egress_handle_request_success(
config, service, mock_client_session, mock_egress_service, mock_tcp_connector
):
"""Test successful request handling"""
# Setup mock response
mock_response = Mock()
mock_method = AsyncMock(return_value=mock_response)
mock_egress_instance = Mock()
mock_egress_instance.test_method = mock_method
mock_egress_service.return_value = mock_egress_instance
# Create test request
test_request = Mock()
response = service._handle_request(test_request, "test_method")
mock_client_session.assert_called_once_with(
connector=mock_tcp_connector.return_value
)
# Verify EgressService initialization
mock_egress_service.assert_called_once_with(
mock_client_session.return_value.__aenter__.return_value,
**service._config.server_configurations,
)
# Verify method call and response
mock_method.assert_called_once_with(test_request)
assert response == mock_response
def test_base_egress_handle_request_connection_error(service, mock_egress_service):
"""Test handling of connection errors"""
# Setup mock error
mock_method = AsyncMock(
side_effect=livekit_api.TwirpError(msg="Connection failed", code=500)
)
mock_egress_instance = Mock()
mock_egress_instance.test_method = mock_method
mock_egress_service.return_value = mock_egress_instance
# Create test request
test_request = Mock()
# Verify error handling
with pytest.raises(WorkerConnectionError) as exc:
service._handle_request(test_request, "test_method")
assert "LiveKit client connection error" in str(exc.value)
assert "Connection failed" in str(exc.value)
@pytest.mark.parametrize(
"response_status,expected_result",
[
@@ -224,43 +170,6 @@ def test_base_egress_start_not_implemented(service):
assert "Subclass must implement this method" in str(exc.value)
@pytest.mark.parametrize("verify_ssl", [True, False])
def test_base_egress_ssl_verification_config(verify_ssl):
"""Test SSL verification configuration"""
config = WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"host": "test.livekit.io",
"api_key": "test_key",
"api_secret": "test_secret",
},
verify_ssl=verify_ssl,
bucket_args={
"endpoint": "https://s3.test.com",
"access_key": "test_key",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
},
)
service = BaseEgressService(config)
# Mock ClientSession to capture connector configuration
with patch("aiohttp.ClientSession") as mock_session:
mock_session.return_value.__aenter__ = AsyncMock()
mock_session.return_value.__aexit__ = AsyncMock()
# Trigger request to verify connector configuration
service._handle_request(Mock(), "test_method")
# Verify SSL configuration
connector = mock_session.call_args[1]["connector"]
assert isinstance(connector, aiohttp.TCPConnector)
assert connector._ssl == verify_ssl
def test_video_composite_egress_hrid(video_service):
"""Test HRID is correct"""
assert video_service.hrid == "video-recording-composite-livekit-egress"
+11 -20
View File
@@ -776,9 +776,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
@@ -794,15 +795,11 @@ def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Assert
# Verify the API was initialized with correct configuration
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
@@ -813,8 +810,8 @@ def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_success(mock_livekit_api, lobby_service):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
"""Test successful participant notification."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
@@ -830,14 +827,11 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
lobby_service.notify_participants(room.id)
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
@@ -855,15 +849,15 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_error(mock_livekit_api, lobby_service):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
"""Test participant notification with API error."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123)
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
@@ -874,7 +868,7 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(
@@ -882,9 +876,6 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
):
lobby_service.notify_participants(room.id)
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
+62
View File
@@ -0,0 +1,62 @@
"""
Test utils functions
"""
from unittest import mock
from core.utils import create_livekit_client
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_ssl_enabled(
mock_livekit_api, mock_get_running_loop, settings
):
"""Test LiveKitAPI client creation with SSL verification enabled."""
mock_get_running_loop.return_value = mock.MagicMock()
settings.LIVEKIT_VERIFY_SSL = True
create_livekit_client()
mock_livekit_api.assert_called_once_with(
**settings.LIVEKIT_CONFIGURATION, session=None
)
@mock.patch("core.utils.aiohttp.ClientSession")
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_ssl_disabled(
mock_livekit_api, mock_get_running_loop, mock_client_session, settings
):
"""Test LiveKitAPI client creation with SSL verification disabled."""
mock_get_running_loop.return_value = mock.MagicMock()
mock_session_instance = mock.MagicMock()
mock_client_session.return_value = mock_session_instance
settings.LIVEKIT_VERIFY_SSL = False
create_livekit_client()
mock_livekit_api.assert_called_once_with(
**settings.LIVEKIT_CONFIGURATION, session=mock_session_instance
)
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_custom_configuration(
mock_livekit_api, mock_get_running_loop, settings
):
"""Test LiveKitAPI client creation with custom configuration."""
settings.LIVEKIT_VERIFY_SSL = True
mock_get_running_loop.return_value = mock.MagicMock()
custom_configuration = {
"api_key": "mock_key",
"api_secret": "mock_secret",
"url": "http://mock-url.com",
}
create_livekit_client(custom_configuration)
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
+17 -1
View File
@@ -13,8 +13,9 @@ from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
import aiohttp
import botocore
from livekit.api import AccessToken, VideoGrants
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
def generate_color(identity: str) -> str:
@@ -142,3 +143,18 @@ def generate_s3_authorization_headers(key):
auth.add_auth(request)
return request
def create_livekit_client(custom_configuration=None):
"""Create and return a configured LiveKit API client."""
custom_session = None
if not settings.LIVEKIT_VERIFY_SSL:
connector = aiohttp.TCPConnector(ssl=False)
custom_session = aiohttp.ClientSession(connector=connector)
# Use default configuration if none provided
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
return LiveKitAPI(session=custom_session, **configuration)
+6 -3
View File
@@ -312,6 +312,9 @@ class Base(Configuration):
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
),
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
}
# Mail
@@ -446,6 +449,9 @@ class Base(Configuration):
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
)
@@ -460,9 +466,6 @@ class Base(Configuration):
RECORDING_OUTPUT_FOLDER = values.Value(
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
)
RECORDING_VERIFY_SSL = values.BooleanValue(
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
)
RECORDING_WORKER_CLASSES = values.DictValue(
{
"screen_recording": "core.recording.worker.services.VideoCompositeEgressService",
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.19"
version = "0.1.20"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -55,7 +55,7 @@ dependencies = [
"sentry-sdk==2.24.1",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.8.2",
"livekit-api==1.0.2",
"aiohttp==3.11.14",
]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.19",
"version": "0.1.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.19",
"version": "0.1.20",
"dependencies": {
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.19",
"version": "0.1.20",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+4 -1
View File
@@ -13,14 +13,17 @@ import { routes } from './routes'
import './i18n/init'
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
const isSDKContext = useIsSdkContext()
return (
<QueryClientProvider client={queryClient}>
<AppInitialization />
{!isSDKContext && <AppInitialization />}
<Suspense fallback={null}>
<I18nProvider locale={i18n.language}>
<Layout>
+3
View File
@@ -11,6 +11,9 @@ export interface ApiConfig {
support?: {
id: string
}
feedback: {
url: string
}
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
recording?: {
@@ -2,15 +2,9 @@ import { silenceLiveKitLogs } from '@/utils/livekit'
import { useConfig } from '@/api/useConfig'
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
import { useSupport } from '@/features/support/hooks/useSupport'
import { useLocation } from 'wouter'
import { useSyncUserPreferencesWithBackend } from '@/features/auth'
const SDK_BASE_ROUTE = '/sdk'
export const AppInitialization = () => {
const { data } = useConfig()
const [location] = useLocation()
useSyncUserPreferencesWithBackend()
const {
analytics = {},
@@ -18,10 +12,8 @@ export const AppInitialization = () => {
silence_livekit_debug_logs = false,
} = data || {}
const isSDKContext = location.includes(SDK_BASE_ROUTE)
useAnalytics({ ...analytics, isDisabled: isSDKContext })
useSupport({ ...support, isDisabled: isSDKContext })
useAnalytics(analytics)
useSupport(support)
silenceLiveKitLogs(silence_livekit_debug_logs)
@@ -2,10 +2,14 @@ import { css } from '@/styled-system/css'
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Text, A } from '@/primitives'
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
import { useConfig } from '@/api/useConfig'
export const FeedbackBanner = () => {
const { t } = useTranslation()
const { data } = useConfig()
if (!data?.feedback?.url) return
return (
<div
className={css({
@@ -35,7 +39,7 @@ export const FeedbackBanner = () => {
gap: 0.25,
})}
>
<A href={GRIST_FEEDBACKS_FORM} target="_blank" size="sm">
<A href={data?.feedback?.url} target="_blank" size="sm">
{t('feedback.cta')}
</A>
<RiExternalLinkLine size={16} aria-hidden="true" />
@@ -26,7 +26,7 @@ export const fetchUser = (
// make sure to not resolve the promise while trying to silent login
// so that consumers of fetchUser don't think the work already ended
if (opts.attemptSilent && canAttemptSilentLogin()) {
attemptSilentLogin(300)
attemptSilentLogin(30)
} else {
resolve(false)
}
@@ -353,7 +353,6 @@ export const Join = ({
type="text"
onChange={setUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
defaultValue={initialUserChoices?.username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
@@ -6,6 +6,7 @@ import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react'
import { PostHog } from 'posthog-js'
import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
const Card = styled('div', {
base: {
@@ -299,6 +300,7 @@ const AuthenticationMessage = ({
}
export const Rating = () => {
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const posthog = usePostHog()
const isUserAnonymous = useMemo(() => {
@@ -307,6 +309,8 @@ export const Rating = () => {
const [step, setStep] = useState(0)
if (!isAnalyticsEnabled) return
if (step == 0) {
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
}
@@ -0,0 +1,42 @@
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useParticipantInfo } from '@livekit/components-react'
import { Participant } from 'livekit-client'
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={{
paddingBottom: '0.1rem',
marginLeft: '0.4rem',
}}
>
{t('screenShare', { name: displayedName })}
</Text>
)
}
return (
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
}}
>
{displayedName}
</Text>
)
}
@@ -2,7 +2,6 @@ import {
AudioTrack,
ConnectionQualityIndicator,
LockLockedIcon,
ParticipantName,
ParticipantTileProps,
ScreenShareIcon,
useEnsureTrackRef,
@@ -29,6 +28,7 @@ import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -97,6 +97,8 @@ export const ParticipantTile: (
participant: trackReference.participant,
})
const isScreenShare = trackReference.source != Track.Source.Camera
return (
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
@@ -129,45 +131,50 @@ export const ParticipantTile: (
{!disableMetadata && (
<div className="lk-participant-metadata">
<HStack gap={0.25}>
<MutedMicIndicator
participant={trackReference.participant}
/>
{!isScreenShare && (
<MutedMicIndicator
participant={trackReference.participant}
/>
)}
<div
className="lk-participant-metadata-item"
style={{
minHeight: '24px',
backgroundColor: isHandRaised ? 'white' : undefined,
color: isHandRaised ? 'black' : undefined,
padding: '0.1rem 0.25rem',
backgroundColor:
isHandRaised && !isScreenShare ? 'white' : undefined,
color:
isHandRaised && !isScreenShare ? 'black' : undefined,
transition: 'background 200ms ease, color 400ms ease',
}}
>
{trackReference.source === Track.Source.Camera ? (
<>
{isHandRaised && (
<RiHand
color="black"
size={16}
style={{
marginInlineEnd: '.25rem', // fixme - match TrackMutedIndicator styling
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
)}
{isEncrypted && (
<LockLockedIcon
style={{ marginRight: '0.25rem' }}
/>
)}
<ParticipantName />
</>
) : (
<>
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
<ParticipantName>&apos;s screen</ParticipantName>
</>
{isHandRaised && !isScreenShare && (
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
)}
{isScreenShare && (
<ScreenShareIcon
style={{
maxWidth: '20px',
width: '100%',
}}
/>
)}
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
@@ -110,9 +110,10 @@ const StyledSidePanel = ({
type PanelProps = {
isOpen: boolean
children: React.ReactNode
keepAlive?: boolean
}
const Panel = ({ isOpen, children }: PanelProps) => (
const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
<div
style={{
display: isOpen ? 'inherit' : 'none',
@@ -121,7 +122,7 @@ const Panel = ({ isOpen, children }: PanelProps) => (
flexGrow: 1,
}}
>
{children}
{keepAlive || isOpen ? children : null}
</div>
)
@@ -160,7 +161,7 @@ export const SidePanel = () => {
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen}>
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen}>
@@ -2,14 +2,17 @@ import { RiMegaphoneLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
import { useConfig } from '@/api/useConfig'
export const FeedbackMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { data } = useConfig()
if (!data?.feedback?.url) return
return (
<MenuItem
href={GRIST_FEEDBACKS_FORM}
href={data?.feedback?.url}
target="_blank"
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
@@ -12,6 +12,7 @@ import {
} from '@/features/rooms/livekit/components/ReactionPortal'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
// eslint-disable-next-line react-refresh/only-export-components
export const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
@@ -56,6 +57,12 @@ export const ReactionsToggle = () => {
}, ANIMATION_DURATION)
}
const debouncedSendReaction = useRateLimiter({
callback: sendReaction,
maxCalls: 10,
windowMs: 1000,
})
// Custom animation implementation for the emoji toolbar
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
// animation isn't perfect
@@ -127,7 +134,7 @@ export const ReactionsToggle = () => {
{EMOJIS.map((emoji, index) => (
<Button
key={index}
onPress={() => sendReaction(emoji)}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji })}
variant="primaryTextDark"
size="sm"
@@ -14,7 +14,6 @@ import {
RiMore2Line,
RiSettings3Line,
} from '@remixicon/react'
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
@@ -24,6 +23,7 @@ import { useSettingsDialog } from '../../components/controls/SettingsDialogConte
import { ResponsiveMenu } from './ResponsiveMenu'
import { ToolsToggle } from '../../components/controls/ToolsToggle'
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
import { useConfig } from '@/api/useConfig'
export function MobileControlBar({
onDeviceError,
@@ -38,6 +38,8 @@ export function MobileControlBar({
const { toggleEffects } = useSidePanel()
const { setDialogOpen } = useSettingsDialog()
const { data } = useConfig()
return (
<>
<div
@@ -150,17 +152,19 @@ export function MobileControlBar({
>
<RiAccountBoxLine size={20} />
</Button>
<LinkButton
href={GRIST_FEEDBACKS_FORM}
variant="primaryTextDark"
tooltip={t('options.items.feedback')}
aria-label={t('options.items.feedback')}
description={true}
target="_blank"
onPress={() => setIsMenuOpened(false)}
>
<RiMegaphoneLine size={20} />
</LinkButton>
{data?.feedback?.url && (
<LinkButton
href={data?.feedback?.url}
variant="primaryTextDark"
tooltip={t('options.items.feedback')}
aria-label={t('options.items.feedback')}
description={true}
target="_blank"
onPress={() => setIsMenuOpened(false)}
>
<RiMegaphoneLine size={20} />
</LinkButton>
)}
<Button
onPress={() => {
setDialogOpen(true)
@@ -0,0 +1,8 @@
import { useLocation } from 'wouter'
const SDK_BASE_ROUTE = '/sdk'
export const useIsSdkContext = () => {
const [location] = useLocation()
return location.includes(SDK_BASE_ROUTE)
}
+37
View File
@@ -0,0 +1,37 @@
import { useCallback, useRef } from 'react'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type RateLimiterProps<T extends (...args: any[]) => any> = {
callback: T
maxCalls: number
windowMs: number
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function useRateLimiter<T extends (...args: any[]) => any>({
callback,
maxCalls = 5,
windowMs = 1000,
}: RateLimiterProps<T>) {
const callsCountRef = useRef(0)
const resetTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const rateLimitedFn = useCallback(
(...args: Parameters<T>) => {
if (callsCountRef.current < maxCalls) {
callsCountRef.current += 1
if (callsCountRef.current === 1) {
resetTimeoutRef.current = setTimeout(() => {
callsCountRef.current = 0
resetTimeoutRef.current = undefined
}, windowMs)
}
return callback(...args)
} else {
return null
}
},
[callback, maxCalls, windowMs]
)
return rateLimitedFn
}
+3
View File
@@ -363,5 +363,8 @@
"message": "",
"stop": "",
"ignore": ""
},
"participantTile": {
"screenShare": ""
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"heading": "An error occurred while loading the page"
},
"feedback": {
"context": "Visio is still in early development — your input matters!",
"context": "Product under development — your input matters!",
"cta": "Share your feedback"
},
"forbidden": {
+3
View File
@@ -364,5 +364,8 @@
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
"stop": "Stop presenting",
"ignore": "Ignore"
},
"participantTile": {
"screenShare": "{{name}}'s screen"
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"heading": "Une erreur est survenue lors du chargement de la page"
},
"feedback": {
"context": "Visio est en pleine construction — votre avis compte !",
"context": "Produit en cours de développement — votre avis compte !",
"cta": "Partagez votre avis"
},
"forbidden": {
+3
View File
@@ -364,5 +364,8 @@
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
"stop": "Arrêter la présentation",
"ignore": "Ignorer"
},
"participantTile": {
"screenShare": "Écran de {{name}}"
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"heading": "Er is een fout opgetreden bij het laden van de pagina"
},
"feedback": {
"context": "Visio is nog in vroege ontwikkeling - uw input is belangrijk!",
"context": "Product in ontwikkeling - uw input is belangrijk!",
"cta": "Deel uw feedback"
},
"forbidden": {
+3
View File
@@ -364,5 +364,8 @@
"message": "Om niet oneindige uw scherm in zichzelf te delen, kunt u beter niet het hele scherm delen. Deel in plaats daarvan een tab of een ander venster.",
"stop": "Stop met presenteren",
"ignore": "Negeren"
},
"participantTile": {
"screenShare": "{{name}} scherm"
}
}
-3
View File
@@ -1,6 +1,3 @@
export const GRIST_FEEDBACKS_FORM =
'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26' as const
export const BETA_USERS_FORM_URL =
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17' as const
@@ -55,13 +55,13 @@ backend:
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_REGION_NAME: local
RECORDING_ENABLE: True
RECORDING_VERIFY_SSL: True
RECORDING_STORAGE_EVENT_ENABLE: True
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
@@ -83,7 +83,6 @@ backend:
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_REGION_NAME: local
RECORDING_ENABLE: True
RECORDING_VERIFY_SSL: True
RECORDING_STORAGE_EVENT_ENABLE: True
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.19",
"version": "0.1.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.19",
"version": "0.1.20",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.19",
"version": "0.1.20",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -4,7 +4,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.1.19",
"version": "0.1.20",
"type": "module",
"main": "./dist/visio-sdk.umd.cjs",
"module": "./dist/visio-sdk.js",
+1 -1
View File
@@ -620,7 +620,7 @@
},
"library": {
"name": "@gouvfr-lasuite/visio-sdk",
"version": "0.0.1",
"version": "0.1.20",
"dependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.19"
version = "0.1.20"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",