Compare commits

..

3 Commits

Author SHA1 Message Date
Jacques ROUSSEL 4b03946028 🔐(secrets) bump secretBump secret
In order to use our openai api we need new secret
2024-12-04 10:42:56 +01:00
lebaudantoine 68b919c189 🩹(summary) remove temporary audio files saved
I forgot to remove temporary files downloaded from the Minio bucket.
2024-12-04 10:42:56 +01:00
lebaudantoine c5544e87d6 🔧(summary) use our self-deployed models
Configure dev and staging environment to use our self-deployed
models (Whisper and LLM). Secrets need to be updated btw.

Because of outscale LB bug, which timeout after 60s, we need to
connect directly to the svc.
2024-12-04 10:42:15 +01:00
92 changed files with 6868 additions and 2855 deletions
-3
View File
@@ -305,6 +305,3 @@ start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
-2
View File
@@ -118,8 +118,6 @@ $ make build-k8s-cluster
Once the Kubernetes cluster is ready, start the application stack locally:
```shell
$ make start-tilt
or
$ make start-tilt-keycloak # start stack without Pro Connect, use keycloak
```
These commands set up and run your application environment using Tilt for local Kubernetes development.
+1 -1
View File
@@ -38,7 +38,7 @@ docker_build(
]
)
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e dev template .'))
migration = '''
set -eu
+405 -405
View File
File diff suppressed because it is too large Load Diff
+1 -1
Submodule secrets updated: 2ba12db71d...f5aea0c251
+4 -5
View File
@@ -130,18 +130,17 @@ class RoomSerializer(serializers.ModelSerializer):
del output["configuration"]
if role is not None or instance.is_public:
room_id = f"{instance.id!s}"
slug = f"{instance.id!s}"
username = request.query_params.get("username", None)
output["livekit"] = {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room_id,
"room": slug,
"token": utils.generate_token(
room=room_id, user=request.user, username=username
room=slug, user=request.user, username=username
),
"passphrase": utils.get_cached_passphrase(room_id)
}
output["is_administrable"] = is_admin
return output
-36
View File
@@ -47,8 +47,6 @@ from core.recording.worker.mediator import (
from . import permissions, serializers
from livekit import api as livekit_api
# pylint: disable=too-many-ancestors
logger = getLogger(__name__)
@@ -212,10 +210,6 @@ class RoomViewSet(
Allow unregistered rooms when activated.
For unregistered rooms we only return a null id and the livekit room and token.
"""
# todo - determine whether encryption is needed store a shared secret in memory or in redis
# todo - check if a secret already exists, else create one.
try:
instance = self.get_object()
except Http404:
@@ -349,36 +343,6 @@ class RoomViewSet(
{"message": f"Recording stopped for room {room.slug}."}
)
@decorators.action(
detail=False,
methods=["post"],
url_path="livekit-webhook",
permission_classes=[],
authentication_classes=[],
)
def handle_livekit_webhook(self, request, pk=None): # pylint: disable=unused-argument
"""Handle LiveKit webhook events."""
auth_token = request.headers.get("Authorization")
if not auth_token:
return drf_response.Response(
{"error": "Missing LiveKit authentication token"},
status=drf_status.HTTP_401_UNAUTHORIZED
)
token_verifier = livekit_api.TokenVerifier()
webhook_receiver = livekit_api.WebhookReceiver(token_verifier)
webhook_data = webhook_receiver.receive(request.body.decode("utf-8"), auth_token)
# Todo - livekit triggers a webhook for all events, see if we can restrict webhook to a limited number of events.
# Todo - handle Egress stopped / aborted events.
if webhook_data.event == "room_finished":
room_id = webhook_data.room.name
utils.clear_cache_passphrase(room_id)
return drf_response.Response({"message": f"Event processed"})
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
+1 -30
View File
@@ -1,7 +1,7 @@
"""Authentication Backends for the Meet core app."""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
import requests
@@ -10,11 +10,6 @@ from mozilla_django_oidc.auth import (
)
from core.models import User
from core.services.marketing_service import (
ContactCreationError,
ContactData,
get_marketing_service,
)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
@@ -91,10 +86,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
password="!", # noqa: S106
**claims,
)
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
elif not user:
return None
@@ -105,26 +96,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return user
@staticmethod
def signup_to_marketing_email(email):
"""Pragmatic approach to newsletter signup during authentication flow.
Details:
1. Uses a very short timeout (1s) to prevent blocking the auth process
2. Silently fails if the marketing service is down/slow to prioritize user experience
3. Trade-off: May miss some signups but ensures auth flow remains fast
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
"""
try:
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(contact_data, timeout=1)
except (ContactCreationError, ImproperlyConfigured, ImportError):
pass
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
try:
+2 -2
View File
@@ -145,7 +145,7 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
"""Custom callback view for handling the silent login flow."""
"""Custom callback view for handling the silent loging flow."""
@property
def failure_url(self):
@@ -162,7 +162,7 @@ class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
"""Custom authentication view for handling the silent login flow."""
"""Custom authentication view for handling the silent loging flow."""
def get_extra_params(self, request):
"""Handle 'prompt' extra parameter for the silent login flow
-2
View File
@@ -189,7 +189,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
class Meta:
db_table = "meet_user"
ordering = ("-created_at",)
verbose_name = _("user")
verbose_name_plural = _("users")
@@ -305,7 +304,6 @@ class ResourceAccess(BaseModel):
class Meta:
db_table = "meet_resource_access"
ordering = ("-created_at",)
verbose_name = _("Resource access")
verbose_name_plural = _("Resource accesses")
constraints = [
@@ -1,134 +0,0 @@
"""Marketing service in charge of pushing data for marketing automation."""
import logging
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, List, Optional, Protocol
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import brevo_python
logger = logging.getLogger(__name__)
class ContactCreationError(Exception):
"""Raised when the contact creation fails."""
@dataclass
class ContactData:
"""Contact data for marketing service integration."""
email: str
attributes: Optional[Dict[str, str]] = None
list_ids: Optional[List[int]] = None
update_enabled: bool = True
class MarketingServiceProtocol(Protocol):
"""Interface for marketing automation service integrations."""
def create_contact(
self, contact_data: ContactData, timeout: Optional[int] = None
) -> dict:
"""Create or update a contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Service response
Raises:
ContactCreationError: If contact creation fails
"""
class BrevoMarketingService:
"""Brevo marketing automation integration.
Handles:
- Contact management and segmentation
- Marketing campaigns and automation
- Email communications
Configuration via Django settings:
- BREVO_API_KEY: API authentication
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
"""
def __init__(self):
"""Initialize Brevo (ex-sendinblue) marketing service."""
if not settings.BREVO_API_KEY:
raise ImproperlyConfigured("Brevo API key is required")
configuration = brevo_python.Configuration()
configuration.api_key["api-key"] = settings.BREVO_API_KEY
self._api_client = brevo_python.ApiClient(configuration)
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
"""Create or update a Brevo contact.
Args:
contact_data: Contact information and attributes
timeout: API request timeout in seconds
Returns:
dict: Brevo API response
Raises:
ContactCreationError: If contact creation fails
ImproperlyConfigured: If required settings are missing
Note:
Contact attributes must be pre-configured in Brevo.
Changes to attributes can impact existing workflows.
"""
if not settings.BREVO_API_CONTACT_LIST_IDS:
raise ImproperlyConfigured(
"Default Brevo List IDs must be configured in settings."
)
contact_api = brevo_python.ContactsApi(self._api_client)
attributes = {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**(contact_data.attributes or {}),
}
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
contact = brevo_python.CreateContact(
email=contact_data.email,
attributes=attributes,
list_ids=list_ids,
update_enabled=contact_data.update_enabled,
)
api_configurations = {}
if timeout is not None:
api_configurations["_request_timeout"] = timeout
try:
response = contact_api.create_contact(contact, **api_configurations)
except brevo_python.rest.ApiException as err:
logger.exception("Failed to create contact in Brevo")
raise ContactCreationError("Failed to create contact in Brevo") from err
return response
@lru_cache(maxsize=1)
def get_marketing_service() -> MarketingServiceProtocol:
"""Return cached instance of configured marketing service."""
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
return marketing_service_cls()
@@ -1,15 +1,12 @@
"""Unit tests for the Authentication Backends."""
from unittest import mock
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.core.exceptions import SuspiciousOperation
import pytest
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
from core.services import marketing_service
pytestmark = pytest.mark.django_db
@@ -415,139 +412,3 @@ def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
user.refresh_from_db()
assert user.email == "john.doe@example.com"
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_new_user_enabled(mock_signup, monkeypatch, settings):
"""Test marketing signup for new user with settings enabled."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
klass = OIDCAuthenticationBackend()
email = "test@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user.email == email
mock_signup.assert_called_once_with(email)
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_new_user_disabled(mock_signup, monkeypatch, settings):
"""Test no marketing signup for new user with settings disabled."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
klass = OIDCAuthenticationBackend()
email = "test@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user.email == email
mock_signup.assert_not_called()
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_new_user_default_disabled(mock_signup, monkeypatch):
"""Test no marketing signup for new user with settings by default disabled."""
klass = OIDCAuthenticationBackend()
email = "test@example.com"
def get_userinfo_mocked(*args):
return {"sub": "123", "email": email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user.email == email
mock_signup.assert_not_called()
@pytest.mark.parametrize(
"is_signup_enabled",
[True, False],
)
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
def test_marketing_signup_existing_user(
mock_signup, monkeypatch, settings, is_signup_enabled
):
"""Test no marketing signup for existing user regardless of settings."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = is_signup_enabled
klass = OIDCAuthenticationBackend()
db_user = UserFactory(email="test@example.com")
def get_userinfo_mocked(*args):
return {"sub": db_user.sub, "email": db_user.email}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user("test-token", None, None)
assert user == db_user
mock_signup.assert_not_called()
@mock.patch("core.authentication.backends.get_marketing_service")
def test_signup_to_marketing_email_success(mock_marketing):
"""Test successful marketing signup."""
email = "test@example.com"
# Call the method
OIDCAuthenticationBackend.signup_to_marketing_email(email)
# Verify service interaction
mock_service = mock_marketing.return_value
mock_service.create_contact.assert_called_once()
@pytest.mark.parametrize(
"error",
[
ImportError,
ImproperlyConfigured,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_service_initialization_errors(
mock_marketing, error, settings
):
"""Tests errors that occur when trying to get/initialize the marketing service."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@pytest.mark.parametrize(
"error",
[
marketing_service.ContactCreationError,
ImproperlyConfigured,
ImportError,
],
)
@mock.patch("core.authentication.backends.get_marketing_service")
def test_marketing_signup_handles_contact_creation_errors(
mock_marketing, error, settings
):
"""Tests errors that occur during the contact creation process."""
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
mock_marketing.return_value.create_contact.side_effect = error
# Should not raise any exception
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
@@ -1,187 +0,0 @@
"""
Test marketing services.
"""
# pylint: disable=W0621,W0613
from unittest import mock
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
from core.services.marketing_service import (
BrevoMarketingService,
ContactCreationError,
ContactData,
get_marketing_service,
)
def test_init_missing_api_key(settings):
"""Test initialization with missing API key."""
settings.BREVO_API_KEY = None
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
BrevoMarketingService()
def test_create_contact_missing_list_ids(settings):
"""Test contact creation with missing list IDs."""
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = None
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
with pytest.raises(
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_success(mock_contact_api):
"""Test successful contact creation."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.return_value = {"id": "test-id"}
response = brevo_service.create_contact(valid_contact_data)
assert response == {"id": "test-id"}
mock_api.create_contact.assert_called_once()
contact_arg = mock_api.create_contact.call_args[0][0]
assert contact_arg.email == "test@example.com"
assert contact_arg.attributes == {
**settings.BREVO_API_CONTACT_ATTRIBUTES,
**valid_contact_data.attributes,
}
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
assert contact_arg.update_enabled is True
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_with_timeout(mock_contact_api):
"""Test contact creation with timeout."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
brevo_service.create_contact(valid_contact_data, timeout=30)
mock_api.create_contact.assert_called_once()
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_api_error(mock_contact_api):
"""Test contact creation API error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@pytest.fixture
def clear_marketing_cache():
"""Clear marketing service cache between tests."""
get_marketing_service.cache_clear()
yield
get_marketing_service.cache_clear()
def test_get_marketing_service_caching(clear_marketing_cache):
"""Test marketing service caching behavior."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = (
"core.services.marketing_service.BrevoMarketingService"
)
service1 = get_marketing_service()
service2 = get_marketing_service()
assert service1 is service2
assert isinstance(service1, BrevoMarketingService)
def test_get_marketing_service_invalid_class(clear_marketing_cache):
"""Test handling of invalid service class."""
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
with pytest.raises(ImportError):
get_marketing_service()
@mock.patch("core.services.marketing_service.import_string")
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
"""Test service class is instantiated only once."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = (
"core.services.marketing_service.BrevoMarketingService"
)
get_marketing_service.cache_clear()
mock_service_cls = mock.Mock()
mock_service_instance = mock.Mock()
mock_service_cls.return_value = mock_service_instance
mock_import_string.return_value = mock_service_cls
service1 = get_marketing_service()
service2 = get_marketing_service()
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
mock_service_cls.assert_called_once()
assert service1 is service2
assert service1 is mock_service_instance
+1 -1
View File
@@ -66,7 +66,7 @@ def test_api_users_list_query_email():
assert response.status_code == 200
user_ids = [user["id"] for user in response.json()["results"]]
assert user_ids == [str(frank.id), str(nicole.id)]
assert user_ids == [str(nicole.id), str(frank.id)]
def test_api_users_retrieve_me_anonymous():
-41
View File
@@ -14,47 +14,6 @@ from django.conf import settings
from livekit.api import AccessToken, VideoGrants
import secrets
import string
from django.core.cache import cache
from cryptography.fernet import Fernet
import base64
def generate_random_passphrase(length=26):
"""Generate a random passphrase using letters and digits"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
def build_room_passphrase_key(room_id: str) -> str:
"""Build cache key for room passphrase."""
return f"room_passphrase:{room_id}"
def get_cached_passphrase(room_id: str) -> str:
"""Get or generate encrypted passphrase for a room.
Retrieves existing passphrase from cache or generates,
encrypts and caches a new one if not found.
"""
cypher = Fernet(settings.PASSPHRASE_ENCRYPTION_KEY.encode())
cache_key = build_room_passphrase_key(room_id)
encrypted_passphrase = cache.get(cache_key)
if encrypted_passphrase is None:
passphrase = generate_random_passphrase()
encrypted_passphrase = cypher.encrypt(passphrase.encode()).decode()
cache.set(cache_key, encrypted_passphrase, timeout=86400) # 24 hours
return passphrase
return cypher.decrypt(encrypted_passphrase.encode()).decode()
def clear_room_passphrase(room_id: str) -> None:
"""Remove room passphrase from cache."""
cache.delete(build_room_passphrase_key(room_id))
def generate_color(identity: str) -> str:
"""Generates a consistent HSL color based on a given identity string.
+4 -36
View File
@@ -324,10 +324,8 @@ class Base(Configuration):
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
OIDC_CREATE_USER = values.BooleanValue(
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
)
OIDC_VERIFY_SSL = values.BooleanValue(
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
default=True,
environ_name="OIDC_CREATE_USER",
)
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=False,
@@ -457,34 +455,6 @@ class Base(Configuration):
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
False,
environ_name="SIGNUP_NEW_USERS_TO_NEWSLETTER",
environ_prefix=None,
help_text=(
"When enabled, new users are automatically added to mailing list "
"for product updates, marketing communications, and customized emails. "
),
)
MARKETING_SERVICE_CLASS = values.Value(
"core.services.marketing_service.BrevoMarketingService",
environ_name="MARKETING_SERVICE_CLASS",
environ_prefix=None,
)
BREVO_API_KEY = values.Value(
None, environ_name="BREVO_API_KEY", environ_prefix=None
)
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
[],
environ_name="BREVO_API_CONTACT_LIST_IDS",
environ_prefix=None,
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
)
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
PASSPHRASE_ENCRYPTION_KEY = values.Value(environ_name="PASSPHRASE_ENCRYPTION_KEY", environ_prefix=None)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -531,10 +501,8 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
# Add the application name to the Sentry scope
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
class Build(Base):
+15 -16
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.12"
version = "0.1.10"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,21 +25,20 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.90",
"boto3==1.35.68",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.6.0",
"django-countries==7.6.1",
"django-parler==2.3",
"redis==5.2.1",
"redis==5.2.0",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.1.4",
"django==5.1.3",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
@@ -49,16 +48,16 @@ dependencies = [
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.3",
"PyJWT==2.10.1",
"PyJWT==2.10.0",
"python-frontmatter==1.1.0",
"requests==2.32.3",
"sentry-sdk==2.19.2",
"sentry-sdk==2.19.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.8.2",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.8.1",
"aiohttp==3.11.11",
"livekit-api==0.8.0",
"aiohttp==3.11.7",
]
[project.urls]
@@ -70,20 +69,20 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.12.1",
"drf-spectacular-sidecar==2024.11.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"pyfakefs==5.7.3",
"ipython==8.29.0",
"pyfakefs==5.7.1",
"pylint-django==2.6.1",
"pylint==3.3.3",
"pylint==3.3.1",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest==8.3.3",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.8.4",
"ruff==0.8.0",
"types-requests==2.32.0.20241016",
]
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.12",
"version": "0.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.12",
"version": "0.1.10",
"dependencies": {
"@livekit/components-react": "2.6.9",
"@livekit/components-styles": "1.1.4",
@@ -7702,9 +7702,9 @@
"dev": true
},
"node_modules/nanoid": {
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"funding": [
{
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.12",
"version": "0.1.10",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+4 -4
View File
@@ -116,10 +116,10 @@ const config: Config = {
'80%': { transform: 'rotate(20deg)' },
'100%': { transform: 'rotate(0)' },
},
pulse_background: {
'0%': { opacity: '1' },
'50%': { opacity: '0.65' },
'100%': { opacity: '1' },
pulse_mic: {
'0%': { color: 'primary', opacity: '1' },
'50%': { color: 'primary', opacity: '0.8' },
'100%': { color: 'primary', opacity: '1' },
},
},
tokens: defineTokens({
@@ -2,7 +2,9 @@ import { css } from '@/styled-system/css'
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Text, A } from '@/primitives'
import { GRIST_FORM } from '@/utils/constants'
const GRIST_FORM =
'https://grist.numerique.gouv.fr/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4'
export const FeedbackBanner = () => {
const { t } = useTranslation()
@@ -1,6 +0,0 @@
import { useConfig } from '@/api/useConfig.ts'
export const useIsAnalyticsEnabled = () => {
const { data } = useConfig()
return !!data?.analytics?.id
}
@@ -16,11 +16,11 @@ export const MainNotificationToast = () => {
if (isMobileBrowser()) {
return
}
triggerNotificationSound(NotificationType.ParticipantJoined)
triggerNotificationSound(NotificationType.Joined)
toastQueue.add(
{
participant,
type: NotificationType.ParticipantJoined,
type: NotificationType.Joined,
},
{
timeout: 5000,
@@ -67,7 +67,7 @@ export const MainNotificationToast = () => {
const existingToast = toastQueue.visibleToasts.find(
(toast) =>
toast.content.participant === participant &&
toast.content.type === NotificationType.HandRaised
toast.content.type === NotificationType.Raised
)
if (existingToast && prevMetadata.raised && !metadata.raised) {
@@ -76,11 +76,11 @@ export const MainNotificationToast = () => {
}
if (!existingToast && !prevMetadata.raised && metadata.raised) {
triggerNotificationSound(NotificationType.HandRaised)
triggerNotificationSound(NotificationType.Raised)
toastQueue.add(
{
participant,
type: NotificationType.HandRaised,
type: NotificationType.Raised,
},
{ timeout: 5000 }
)
@@ -1,5 +1,6 @@
export enum NotificationType {
ParticipantJoined = 'participantJoined',
HandRaised = 'handRaised',
// todo - implement message received notification
Joined = 'joined',
Default = 'default',
Raised = 'raised',
Lowered = 'lowered',
}
@@ -17,10 +17,10 @@ export function ToastRegion({ state, ...props }: ToastRegionProps) {
return (
<div {...regionProps} ref={ref} className="toast-region">
{state.visibleToasts.map((toast) => {
if (toast.content?.type === NotificationType.ParticipantJoined) {
if (toast.content?.type === NotificationType.Joined) {
return <ToastJoined key={toast.key} toast={toast} state={state} />
}
if (toast.content?.type === NotificationType.HandRaised) {
if (toast.content?.type === NotificationType.Raised) {
return <ToastRaised key={toast.key} toast={toast} state={state} />
}
return <Toast key={toast.key} toast={toast} state={state} />
@@ -1,24 +1,18 @@
import useSound from 'use-sound'
import { useSnapshot } from 'valtio'
import { notificationsStore } from '@/stores/notifications'
import { NotificationType } from '@/features/notifications/NotificationType'
// fixme - handle dynamic audio output changes
export const useNotificationSound = () => {
const notificationsSnap = useSnapshot(notificationsStore)
const [play] = useSound('./sounds/notifications.mp3', {
sprite: {
participantJoined: [0, 1150],
handRaised: [1400, 180],
messageReceived: [1580, 300],
joined: [0, 1150],
raised: [1400, 180],
message: [1580, 300],
waiting: [2039, 710],
success: [2740, 1304],
},
volume: notificationsSnap.soundNotificationVolume,
})
const triggerNotificationSound = (type: NotificationType) => {
const isSoundEnabled = notificationsSnap.soundNotifications.get(type)
if (isSoundEnabled) play({ id: type })
const triggerNotificationSound = (type: string) => {
play({ id: type })
}
return { triggerNotificationSound }
}
@@ -8,7 +8,6 @@ export type ApiRoom = {
url: string
room: string
token: string
passphrase: string
}
configuration?: {
[key: string]: string | number | boolean
@@ -1,13 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
import {
Room,
RoomOptions,
ExternalE2EEKeyProvider,
DeviceUnsupportedError,
} from 'livekit-client'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen'
@@ -22,9 +17,6 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import posthog from 'posthog-js'
import { css } from '@/styled-system/css'
// todo - release worker when quitting the room, same for the key provider?
// todo - check, seems the demo app from livekit trigger the web worker twice because of re-rendering
export const Conference = ({
roomId,
userConfig,
@@ -71,85 +63,20 @@ export const Conference = ({
retry: false,
})
const e2eeEnabled = true
const workerRef = useRef<Worker | null>(null)
const keyProvider = useRef<any | null>(null)
const getKeyProvider = () => {
if (!keyProvider.current && typeof window !== 'undefined') {
keyProvider.current = new ExternalE2EEKeyProvider()
}
return keyProvider.current
}
const getWorker = () => {
if (!e2eeEnabled) {
return
}
if (!workerRef.current && typeof window !== 'undefined') {
workerRef.current = new Worker(
new URL('livekit-client/e2ee-worker', import.meta.url)
)
}
return workerRef.current
}
const e2eePassphrase = data?.livekit?.passphrase
const [e2eeSetupComplete, setE2eeSetupComplete] = useState(false)
const roomOptions = useMemo((): RoomOptions => {
const worker = getWorker()
const keyProvider = getKeyProvider()
// todo - explain why
const videoCodec = e2eeEnabled ? undefined : 'vp9'
const e2ee = e2eeEnabled ? { keyProvider, worker } : undefined
return {
adaptiveStream: true,
dynacast: true,
publishDefaults: {
// todo - explain why
red: !e2eeEnabled,
videoCodec,
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
},
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
},
e2ee,
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
useEffect(() => {
console.log('enter', e2eePassphrase)
if (e2eePassphrase) {
const keyProvider = getKeyProvider()
keyProvider
.setKey(e2eePassphrase)
.then(() => {
room.setE2EEEnabled(true).catch((e) => {
if (e instanceof DeviceUnsupportedError) {
alert(
`You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`
)
console.error(e)
} else {
throw e
}
})
})
.then(() => setE2eeSetupComplete(true))
}
}, [room, e2eePassphrase])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const { t } = useTranslation('rooms')
@@ -170,10 +97,6 @@ export const Conference = ({
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
}
const handleEncryptionError = () => {
console.log('error')
}
return (
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
<Screen header={false} footer={false}>
@@ -181,14 +104,13 @@ export const Conference = ({
room={room}
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={e2eeSetupComplete}
connect={true}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
connectOptions={connectOptions}
className={css({
backgroundColor: 'primaryDark.50 !important',
})}
onEncryptionError={handleEncryptionError}
>
<VideoConference />
{showInviteDialog && (
@@ -121,13 +121,7 @@ const OpenFeedback = ({
>
{t('submit')}
</Button>
<Button
invisible
variant="secondary"
size="sm"
fullWidth
onPress={onNext}
>
<Button invisible size="sm" fullWidth onPress={onNext}>
{t('skip')}
</Button>
</VStack>
@@ -15,9 +15,8 @@ import {
VideoTrack,
TrackRefContext,
ParticipantContextIfNeeded,
useIsSpeaking,
} from '@livekit/components-react'
import React, { useEffect } from 'react'
import React from 'react'
import {
isTrackReference,
isTrackReferencePinned,
@@ -1,43 +0,0 @@
import { css } from '@/styled-system/css'
import { RiRecordCircleLine } from '@remixicon/react'
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useRoomContext } from '@livekit/components-react'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'recording' })
const room = useRoomContext()
if (!room?.isRecording) return
return (
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.25rem 0.35rem',
backgroundColor: 'primaryDark.200',
borderColor: 'primaryDark.400',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RiRecordCircleLine
size={20}
className={css({
color: 'white',
backgroundColor: 'danger.700',
padding: '3px',
borderRadius: '3px',
})}
/>
<Text variant={'sm'}>{t('label')}</Text>
</div>
)
}
@@ -10,7 +10,6 @@ import { useSidePanel } from '../hooks/useSidePanel'
import { ReactNode } from 'react'
import { Effects } from './Effects'
import { Chat } from '../prefabs/Chat'
import { Transcript } from './Transcript'
type StyledSidePanelProps = {
title: string
@@ -107,7 +106,6 @@ export const SidePanel = () => {
isEffectsOpen,
isChatOpen,
isSidePanelOpen,
isTranscriptOpen,
} = useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
@@ -129,9 +127,6 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen}>
<Chat />
</Panel>
<Panel isOpen={isTranscriptOpen}>
<Transcript />
</Panel>
</StyledSidePanel>
)
}
@@ -1,100 +0,0 @@
import { Button, Div, H, Text } from '@/primitives'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { css } from '@/styled-system/css'
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
import { RiRecordCircleLine, RiStopCircleLine } from '@remixicon/react'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useStartRecording,
} from '@/features/rooms/api/startRecording'
import { useStopRecording } from '@/features/rooms/api/stopRecording'
import { useEffect, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
export const Transcript = () => {
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const hasTranscriptAccess = useHasTranscriptAccess()
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const room = useRoomContext()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleTranscript = async () => {
if (!roomId) {
console.warn('No room ID found')
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
if (!hasTranscriptAccess) return
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img src={thirdSlide} alt={'wip'} />
{room.isRecording ? (
<>
<H lvl={2}>{t('stop.heading')}</H>
<Text variant="sm" centered wrap="balance">
{t('stop.body')}
</Text>
<div className={css({ height: '2rem' })} />
<Button isDisabled={isLoading} onPress={() => handleTranscript()}>
<RiStopCircleLine style={{ marginRight: '0.5rem' }} />{' '}
{t('stop.button')}
</Button>
</>
) : (
<>
<H lvl={2}>{t('start.heading')}</H>
<Text variant="sm" centered wrap="balance">
{t('start.body')}
</Text>
<div className={css({ height: '2rem' })} />
<Button isDisabled={isLoading} onPress={() => handleTranscript()}>
<RiRecordCircleLine style={{ marginRight: '0.5rem' }} />{' '}
{t('start.button')}
</Button>
</>
)}
</Div>
)
}
@@ -5,12 +5,8 @@ import { css } from '@/styled-system/css'
import { ToggleButton } from '@/primitives'
import { chatStore } from '@/stores/chat'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const ChatToggle = ({
onPress,
...props
}: Partial<ToggleButtonProps>) => {
export const ChatToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
const chatSnap = useSnapshot(chatStore)
@@ -31,12 +27,8 @@ export const ChatToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isChatOpen}
onPress={(e) => {
toggleChat()
onPress?.(e)
}}
onPress={() => toggleChat()}
data-attr={`controls-chat-${tooltipLabel}`}
{...props}
>
<RiChat1Line />
</ToggleButton>
@@ -1,11 +1,16 @@
import { useTranslation } from 'react-i18next'
import { RiMore2Line } from '@remixicon/react'
import { Button, Menu } from '@/primitives'
import { useState } from 'react'
import { OptionsMenuItems } from '@/features/rooms/livekit/components/controls/Options/OptionsMenuItems'
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
export type DialogState = 'username' | 'settings' | null
export const OptionsButton = () => {
const { t } = useTranslation('rooms')
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
return (
<>
<Menu>
@@ -17,8 +22,12 @@ export const OptionsButton = () => {
>
<RiMore2Line />
</Button>
<OptionsMenuItems />
<OptionsMenuItems onOpenDialog={setDialogOpen} />
</Menu>
<SettingsDialogExtended
isOpen={dialogOpen === 'settings'}
onOpenChange={(v) => !v && setDialogOpen(null)}
/>
</>
)
}
@@ -5,17 +5,21 @@ import {
} from '@remixicon/react'
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { Dispatch, SetStateAction } from 'react'
import { DialogState } from './OptionsButton'
import { Separator } from '@/primitives/Separator'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { useSettingsDialog } from '../SettingsDialogContext'
import { GRIST_FORM } from '@/utils/constants'
import { TranscriptMenuItem } from './TranscriptMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = () => {
export const OptionsMenuItems = ({
onOpenDialog,
}: {
onOpenDialog: Dispatch<SetStateAction<DialogState>>
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggleEffects } = useSidePanel()
const { setDialogOpen } = useSettingsDialog()
return (
<RACMenu
style={{
@@ -31,11 +35,12 @@ export const OptionsMenuItems = () => {
<RiAccountBoxLine size={20} />
{t('effects')}
</MenuItem>
<TranscriptMenuItem />
</Section>
<Separator />
<Section>
<MenuItem
href={GRIST_FORM}
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
target="_blank"
className={menuRecipe({ icon: true }).item}
>
@@ -44,7 +49,7 @@ export const OptionsMenuItems = () => {
</MenuItem>
<MenuItem
className={menuRecipe({ icon: true }).item}
onAction={() => setDialogOpen(true)}
onAction={() => onOpenDialog('settings')}
>
<RiSettings3Line size={20} />
{t('settings')}
@@ -0,0 +1,71 @@
import { RiRecordCircleLine, RiStopCircleLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { MenuItem } from 'react-aria-components'
import {
RecordingMode,
useStartRecording,
} from '@/features/rooms/api/startRecording'
import { useStopRecording } from '@/features/rooms/api/stopRecording'
import { useRoomContext } from '@livekit/components-react'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useConfig } from '@/api/useConfig'
export const TranscriptMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const apiRoomData = useRoomData()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const { data } = useConfig()
const room = useRoomContext()
const handleTranscript = async () => {
const roomId = apiRoomData?.livekit?.room
if (!roomId) {
console.warn('No room ID found')
return
}
try {
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
}
} catch (error) {
console.error('Failed to handle transcript:', error)
}
}
if (
!data?.recording?.is_enabled ||
!data?.recording?.available_modes?.includes(RecordingMode.Transcript) ||
!apiRoomData?.is_administrable
) {
return
}
return (
<MenuItem
className={menuRecipe({ icon: true }).item}
onAction={async () => await handleTranscript()}
>
{room.isRecording ? (
<>
<RiRecordCircleLine size={20} />
{t('transcript.stop')}
</>
) : (
<>
<RiStopCircleLine size={20} />
{t('transcript.start')}
</>
)}
</MenuItem>
)
}
@@ -84,12 +84,9 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
<RiMicOffFill color={'gray'} />
) : (
<RiMicFill
className={css({
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
animation: isSpeaking
? 'pulse_background 800ms infinite'
: undefined,
})}
style={{
animation: isSpeaking ? 'pulse_mic 800ms infinite' : undefined,
}}
/>
)}
</Button>
@@ -4,12 +4,8 @@ import { ToggleButton } from '@/primitives'
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const ParticipantsToggle = ({
onPress,
...props
}: ToggleButtonProps) => {
export const ParticipantsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
/**
@@ -37,12 +33,8 @@ export const ParticipantsToggle = ({
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
onPress={() => toggleParticipants()}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
@@ -4,21 +4,13 @@ import { useTranslation } from 'react-i18next'
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { Track } from 'livekit-client'
import React from 'react'
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
type Props = Omit<
UseTrackToggleProps<Track.Source.ScreenShare>,
'source' | 'captureOptions'
> &
Pick<NonNullable<ButtonRecipeProps>, 'variant'> &
ToggleButtonProps
export const ScreenShareToggle = ({
variant = 'primaryDark',
onPress,
...props
}: Props) => {
export const ScreenShareToggle = (
props: Omit<
UseTrackToggleProps<Track.Source.ScreenShare>,
'source' | 'captureOptions'
>
) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.screenShare' })
const { buttonProps, enabled } = useTrackToggle({
...props,
@@ -34,16 +26,18 @@ export const ScreenShareToggle = ({
<ToggleButton
isSelected={enabled}
square
variant={variant}
variant="primaryDark"
tooltip={t(tooltipLabel)}
onPress={(e) => {
onPress={(e) =>
buttonProps.onClick?.(
e as unknown as React.MouseEvent<HTMLButtonElement, MouseEvent>
)
onPress?.(e)
}
style={{
maxWidth: '46px',
maxHeight: '46px',
}}
data-attr={`controls-screenshare-${tooltipLabel}`}
{...props}
>
<Div position="relative">
<RiRectangleLine size={28} />
@@ -1,36 +0,0 @@
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
import React, { createContext, useContext, useState } from 'react'
const SettingsDialogContext = createContext<
| {
dialogOpen: boolean
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
}
| undefined
>(undefined)
export const SettingsDialogProvider: React.FC<{
children: React.ReactNode
}> = ({ children }) => {
const [dialogOpen, setDialogOpen] = useState(false)
return (
<SettingsDialogContext.Provider value={{ dialogOpen, setDialogOpen }}>
{children}
<SettingsDialogExtended
isOpen={dialogOpen}
onOpenChange={(v) => !v && setDialogOpen(false)}
/>
</SettingsDialogContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useSettingsDialog = () => {
const context = useContext(SettingsDialogContext)
if (!context) {
throw new Error(
'useSettingsDialog must be used within a SettingsDialogProvider'
)
}
return context
}
@@ -3,9 +3,8 @@ import { RiQuestionLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Crisp } from 'crisp-sdk-web'
import { useEffect, useState } from 'react'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
export const SupportToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
const [isOpened, setIsOpened] = useState($crisp.is('chat:opened'))
@@ -33,16 +32,8 @@ export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
tooltip={t('support')}
aria-label={t('support')}
isSelected={isOpened}
onPress={(e) => {
if (isOpened) {
Crisp.chat.close()
} else {
Crisp.chat.open()
}
onPress?.(e)
}}
onPress={() => (isOpened ? Crisp.chat.close() : Crisp.chat.open())}
data-attr="controls-support"
{...props}
>
<RiQuestionLine />
</ToggleButton>
@@ -1,46 +0,0 @@
import { ToggleButton } from '@/primitives'
import { RiBardLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useSidePanel } from '../../hooks/useSidePanel'
import { useHasTranscriptAccess } from '../../hooks/useHasTranscriptAccess'
import { css } from '@/styled-system/css'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
export const TranscriptToggle = ({
variant = 'primaryDark',
onPress,
...props
}: ToggleButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.transcript' })
const { isTranscriptOpen, toggleTranscript } = useSidePanel()
const tooltipLabel = isTranscriptOpen ? 'open' : 'closed'
const hasTranscriptAccess = useHasTranscriptAccess()
if (!hasTranscriptAccess) return
return (
<div
className={css({
position: 'relative',
display: 'inline-block',
})}
>
<ToggleButton
square
variant={variant}
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isTranscriptOpen}
onPress={(e) => {
toggleTranscript()
onPress?.(e)
}}
{...props}
>
<RiBardLine />
</ToggleButton>
</div>
)
}
@@ -1,17 +0,0 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { useIsTranscriptEnabled } from './useIsTranscriptEnabled'
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
export const useHasTranscriptAccess = () => {
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isTranscriptEnabled = useIsTranscriptEnabled()
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
isTranscriptEnabled
)
}
@@ -1,6 +0,0 @@
import { useRoomData } from './useRoomData'
export const useIsAdminOrOwner = () => {
const apiRoomData = useRoomData()
return apiRoomData?.is_administrable
}
@@ -1,11 +0,0 @@
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { useConfig } from '@/api/useConfig'
export const useIsTranscriptEnabled = () => {
const { data } = useConfig()
return (
data?.recording?.is_enabled &&
data?.recording?.available_modes?.includes(RecordingMode.Transcript)
)
}
@@ -1,6 +0,0 @@
import { useRoomData } from './useRoomData'
export const useRoomId = () => {
const apiRoomData = useRoomData()
return apiRoomData?.livekit?.room
}
@@ -5,7 +5,6 @@ export enum PanelId {
PARTICIPANTS = 'participants',
EFFECTS = 'effects',
CHAT = 'chat',
TRANSCRIPT = 'transcript',
}
export const useSidePanel = () => {
@@ -15,7 +14,6 @@ export const useSidePanel = () => {
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
const isChatOpen = activePanelId == PanelId.CHAT
const isTranscriptOpen = activePanelId == PanelId.TRANSCRIPT
const isSidePanelOpen = !!activePanelId
const toggleParticipants = () => {
@@ -30,20 +28,14 @@ export const useSidePanel = () => {
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
}
const toggleTranscript = () => {
layoutStore.activePanelId = isTranscriptOpen ? null : PanelId.TRANSCRIPT
}
return {
activePanelId,
toggleParticipants,
toggleChat,
toggleEffects,
toggleTranscript,
isChatOpen,
isParticipantsOpen,
isEffectsOpen,
isSidePanelOpen,
isTranscriptOpen,
}
}
@@ -0,0 +1,163 @@
import { Track } from 'livekit-client'
import * as React from 'react'
import { supportsScreenSharing } from '@livekit/components-core'
import { usePersistentUserChoices } from '@livekit/components-react'
import { StartMediaButton } from '../components/controls/StartMediaButton'
import { OptionsButton } from '../components/controls/Options/OptionsButton'
import { ParticipantsToggle } from '../components/controls/Participants/ParticipantsToggle'
import { ChatToggle } from '../components/controls/ChatToggle'
import { HandToggle } from '../components/controls/HandToggle'
import { SelectToggleDevice } from '../components/controls/SelectToggleDevice'
import { LeaveButton } from '../components/controls/LeaveButton'
import { ScreenShareToggle } from '../components/controls/ScreenShareToggle'
import { css } from '@/styled-system/css'
import { SupportToggle } from '@/features/rooms/livekit/components/controls/SupportToggle.tsx'
/** @public */
export type ControlBarControls = {
microphone?: boolean
camera?: boolean
chat?: boolean
screenShare?: boolean
leave?: boolean
settings?: boolean
}
/** @public */
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
variation?: 'minimal' | 'verbose' | 'textOnly'
controls?: ControlBarControls
/**
* If `true`, the user's device choices will be persisted.
* This will enable the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
saveUserChoices?: boolean
}
/**
* The `ControlBar` prefab gives the user the basic user interface to control their
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
*
* @remarks
* This component is build with other LiveKit components like `TrackToggle`,
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
*
* @example
* ```tsx
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @public
*/
export function ControlBar({
saveUserChoices = true,
onDeviceError,
}: ControlBarProps) {
const browserSupportsScreenSharing = supportsScreenSharing()
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices({ preventSave: !saveUserChoices })
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const cameraOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
return (
<div
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'flex-start',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
marginLeft: '0.5rem',
})}
></div>
<div
className={css({
flex: '1 1 33%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
gap: '0.65rem',
})}
>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
/>
)}
<HandToggle />
<OptionsButton />
<LeaveButton />
<StartMediaButton />
</div>
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
paddingRight: '0.25rem',
})}
>
<ChatToggle />
<ParticipantsToggle />
<SupportToggle />
</div>
</div>
)
}
@@ -1,103 +0,0 @@
import { Track } from 'livekit-client'
import * as React from 'react'
import { usePersistentUserChoices } from '@livekit/components-react'
import { MobileControlBar } from './MobileControlBar'
import { DesktopControlBar } from './DesktopControlBar'
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
import { useIsMobile } from '@/utils/useIsMobile'
/** @public */
export type ControlBarControls = {
microphone?: boolean
camera?: boolean
chat?: boolean
screenShare?: boolean
leave?: boolean
settings?: boolean
}
/** @public */
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
variation?: 'minimal' | 'verbose' | 'textOnly'
controls?: ControlBarControls
/**
* If `true`, the user's device choices will be persisted.
* This will enable the user to have the same device choices when they rejoin the room.
* @defaultValue true
* @alpha
*/
saveUserChoices?: boolean
}
/**
* The `ControlBar` prefab gives the user the basic user interface to control their
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
*
* @remarks
* This component is build with other LiveKit components like `TrackToggle`,
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
*
* @example
* ```tsx
* <LiveKitRoom>
* <ControlBar />
* </LiveKitRoom>
* ```
* @public
*/
export function ControlBar({
saveUserChoices = true,
onDeviceError,
}: ControlBarProps) {
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices({ preventSave: !saveUserChoices })
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const cameraOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
const barProps = {
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}
const isMobile = useIsMobile()
return (
<SettingsDialogProvider>
{isMobile ? (
<MobileControlBar {...barProps} />
) : (
<DesktopControlBar {...barProps} />
)}
</SettingsDialogProvider>
)
}
export interface ControlBarAuxProps {
onDeviceError: ControlBarProps['onDeviceError']
microphoneOnChange: (
enabled: boolean,
isUserInitiated: boolean
) => void | null
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
saveAudioInputDeviceId: (deviceId: string) => void
saveVideoInputDeviceId: (deviceId: string) => void
}
@@ -1,106 +0,0 @@
import { supportsScreenSharing } from '@livekit/components-core'
import { ControlBarAuxProps } from './ControlBar'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { HandToggle } from '../../components/controls/HandToggle'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { SupportToggle } from '../../components/controls/SupportToggle'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
export function DesktopControlBar({
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const browserSupportsScreenSharing = supportsScreenSharing()
return (
<>
<div
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'flex-start',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
marginLeft: '0.5rem',
})}
></div>
<div
className={css({
flex: '1 1 33%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
gap: '0.65rem',
})}
>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
/>
)}
<HandToggle />
<OptionsButton />
<LeaveButton />
<StartMediaButton />
</div>
<div
className={css({
display: 'flex',
justifyContent: 'flex-end',
flex: '1 1 33%',
alignItems: 'center',
gap: '0.5rem',
paddingRight: '0.25rem',
})}
>
<ChatToggle />
<ParticipantsToggle />
<TranscriptToggle />
<SupportToggle />
</div>
</div>
</>
)
}
@@ -1,182 +0,0 @@
import { supportsScreenSharing } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { ControlBarAuxProps } from './ControlBar'
import React from 'react'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { HandToggle } from '../../components/controls/HandToggle'
import { Button } from '@/primitives/Button'
import {
RiAccountBoxLine,
RiMegaphoneLine,
RiMore2Line,
RiSettings3Line,
} from '@remixicon/react'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
import { SupportToggle } from '../../components/controls/SupportToggle'
import { useSidePanel } from '../../hooks/useSidePanel'
import { LinkButton } from '@/primitives'
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
import { ResponsiveMenu } from './ResponsiveMenu'
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
export function MobileControlBar({
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
const { t } = useTranslation('rooms')
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
const browserSupportsScreenSharing = supportsScreenSharing()
const { toggleEffects } = useSidePanel()
const { setDialogOpen } = useSettingsDialog()
return (
<>
<div
className={css({
width: '100vw',
display: 'flex',
position: 'absolute',
padding: '1.125rem',
justifyContent: 'center',
bottom: 0,
left: 0,
right: 0,
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
width: '422px',
})}
>
<LeaveButton />
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
/>
<HandToggle />
<Button
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
tooltip={t('options.buttonLabel')}
onPress={() => setIsMenuOpened(true)}
>
<RiMore2Line />
</Button>
</div>
</div>
<ResponsiveMenu
isOpened={isMenuOpened}
onClosed={() => setIsMenuOpened(false)}
>
<div
className={css({
display: 'flex',
justifyContent: 'center',
})}
>
<div
className={css({
flexGrow: 1,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
gridGap: '1rem',
'& > *': {
alignSelf: 'center',
justifySelf: 'center',
},
})}
>
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.ScreenShare, error })
}
variant="primaryTextDark"
description={true}
onPress={() => setIsMenuOpened(false)}
/>
)}
<ChatToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<ParticipantsToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<TranscriptToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<SupportToggle
description={true}
onPress={() => setIsMenuOpened(false)}
/>
<Button
onPress={() => {
toggleEffects()
setIsMenuOpened(false)
}}
variant="primaryTextDark"
aria-label={t('options.items.effects')}
tooltip={t('options.items.effects')}
description={true}
>
<RiAccountBoxLine size={20} />
</Button>
<LinkButton
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
variant="primaryTextDark"
tooltip={t('options.items.feedbacks')}
aria-label={t('options.items.feedbacks')}
description={true}
target="_blank"
onPress={() => setIsMenuOpened(false)}
>
<RiMegaphoneLine size={20} />
</LinkButton>
<Button
onPress={() => {
setDialogOpen(true)
setIsMenuOpened(false)
}}
variant="primaryTextDark"
aria-label={t('options.items.settings')}
tooltip={t('options.items.settings')}
description={true}
>
<RiSettings3Line size={20} />
</Button>
</div>
</div>
</ResponsiveMenu>
</>
)
}
@@ -1,56 +0,0 @@
import { css } from '@/styled-system/css'
import { PropsWithChildren } from 'react'
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
interface ResponsiveMenuProps extends PropsWithChildren {
isOpened: boolean
onClosed: () => void
}
export function ResponsiveMenu({
isOpened,
onClosed,
children,
}: ResponsiveMenuProps) {
return (
<ModalOverlay
isDismissable
isOpen={isOpened}
onOpenChange={(isOpened) => {
if (!isOpened) {
onClosed()
}
}}
className={css({
width: '100vw',
height: 'var(--visual-viewport-height)',
zIndex: 100,
justifyContent: 'center',
alignItems: 'flex-end',
display: 'flex',
position: 'fixed',
top: 0,
left: 0,
padding: '1.5rem 1.5rem 1rem 1.5rem',
boxSizing: 'border-box',
})}
>
<Modal
className={css({
backgroundColor: 'primaryDark.200',
borderRadius: '20px',
flexGrow: 1,
padding: '1.5rem',
'&[data-entering]': {
animation: 'slide-full 200ms',
},
'&[data-exiting]': {
animation: 'slide-full 200ms reverse',
},
})}
>
<Dialog>{children}</Dialog>
</Modal>
</ModalOverlay>
)
}
@@ -20,7 +20,7 @@ import {
useCreateLayoutContext,
} from '@livekit/components-react'
import { ControlBar } from './ControlBar/ControlBar'
import { ControlBar } from './ControlBar'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
@@ -28,7 +28,6 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingStateToast } from '../components/RecordingStateToast'
const LayoutWrapper = styled(
'div',
@@ -213,7 +212,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
)}
<RoomAudioRenderer />
<ConnectionStateToast />
<RecordingStateToast />
</div>
)
}
@@ -6,14 +6,12 @@ import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
RiAccountCircleLine,
RiNotification3Line,
RiSettings3Line,
RiSpeakerLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { GeneralTab } from '@/features/settings/components/tabs/GeneralTab.tsx'
import { AudioTab } from '@/features/settings/components/tabs/AudioTab.tsx'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useRef } from 'react'
@@ -83,17 +81,12 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<RiSettings3Line />
{isWideScreen && t('tabs.general')}
</Tab>
<Tab icon highlight id="4">
<RiNotification3Line />
{isWideScreen && t('tabs.notifications')}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
<AccountTab id="1" onOpenChange={props.onOpenChange} />
<AudioTab id="2" />
<GeneralTab id="3" />
<NotificationsTab id="4" />
</div>
</Tabs>
</Dialog>
@@ -1,41 +0,0 @@
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { H, Switch } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { notificationsStore } from '@/stores/notifications'
export type NotificationsTabProps = Pick<TabPanelProps, 'id'>
export const NotificationsTab = ({ id }: NotificationsTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'notifications' })
const notificationsSnap = useSnapshot(notificationsStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('heading')}</H>
<ul
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
{Array.from(notificationsSnap.soundNotifications).map(
([key, value]) => (
<li key={key}>
<Switch
aria-label={`${t(`actions.${value ? 'disable' : 'enable'}`)} ${t('label')} "${t(`items.${key}`)}"`}
isSelected={value}
onChange={(v) => {
notificationsStore.soundNotifications.set(key, v)
}}
>
{t(`items.${key}`)}
</Switch>
</li>
)
)}
</ul>
</TabPanel>
)
}
+7 -24
View File
@@ -71,10 +71,6 @@
"open": "",
"closed": ""
},
"transcript": {
"open": "",
"closed": ""
},
"support": ""
},
"options": {
@@ -83,7 +79,11 @@
"feedbacks": "",
"settings": "",
"username": "",
"effects": ""
"effects": "",
"transcript": {
"start": "",
"stop": ""
}
}
},
"effects": {
@@ -102,32 +102,18 @@
"heading": {
"participants": "",
"effects": "",
"chat": "",
"transcript": ""
"chat": ""
},
"content": {
"participants": "",
"effects": "",
"chat": "",
"transcript": ""
"chat": ""
},
"closeButton": ""
},
"chat": {
"disclaimer": ""
},
"transcript": {
"start": {
"heading": "",
"body": "",
"button": ""
},
"stop": {
"heading": "",
"body": "",
"button": ""
}
},
"rating": {
"submit": "",
"question": "",
@@ -164,8 +150,5 @@
"raisedHands": "",
"lowerParticipantHand": "",
"lowerParticipantsHand": ""
},
"recording": {
"label": ""
}
}
+1 -15
View File
@@ -19,19 +19,6 @@
},
"permissionsRequired": ""
},
"notifications": {
"heading": "",
"label": "",
"actions": {
"disable": "",
"enable": ""
},
"items": {
"participantJoined": "",
"handRaised": "",
"messageReceived": ""
}
},
"dialog": {
"heading": ""
},
@@ -43,7 +30,6 @@
"tabs": {
"account": "",
"audio": "",
"general": "",
"notifications": ""
"general": ""
}
}
+7 -24
View File
@@ -70,10 +70,6 @@
"open": "Hide everyone",
"closed": "See everyone"
},
"transcript": {
"open": "Hide AI assistant",
"closed": "Show AI assistant"
},
"support": "Support"
},
"options": {
@@ -82,7 +78,11 @@
"feedbacks": "Give us feedbacks",
"settings": "Settings",
"username": "Update Your Name",
"effects": "Apply effects"
"effects": "Apply effects",
"transcript": {
"start": "Start meeting transcription",
"stop": "Stop ongoing transcription"
}
}
},
"effects": {
@@ -101,32 +101,18 @@
"heading": {
"participants": "Participants",
"effects": "Effects",
"chat": "Messages in the chat",
"transcript": "AI Assistant"
"chat": "Messages in the chat"
},
"content": {
"participants": "participants",
"effects": "effects",
"chat": "messages",
"transcript": "AI assistant"
"chat": "messages"
},
"closeButton": "Hide {{content}}"
},
"chat": {
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
},
"transcript": {
"start": {
"heading": "Start the Assistant!",
"body": "The assistant automatically starts recording your meeting audio (limited to 1 hour). At the end, you'll receive a clear and concise summary of the discussion directly via email.",
"button": "Start"
},
"stop": {
"heading": "Recording in Progress...",
"body": "Your meeting is currently being recorded. You will receive a summary via email once the meeting ends.",
"button": "Stop Recording"
}
},
"rating": {
"submit": "Submit",
"question": "What do you think about the quality of your call?",
@@ -163,8 +149,5 @@
"raisedHands": "Raised hands",
"lowerParticipantHand": "Lower {{name}}'s hand",
"lowerParticipantsHand": "Lower all hands"
},
"recording": {
"label": "Recording"
}
}
+1 -15
View File
@@ -19,19 +19,6 @@
},
"permissionsRequired": "Permissions required"
},
"notifications": {
"heading": "Sound notifications",
"label": "sound notifications for",
"actions": {
"disable": "Disable",
"enable": "Enable"
},
"items": {
"participantJoined": "Participant joined",
"handRaised": "Hand raised",
"messageReceived": "Message received"
}
},
"dialog": {
"heading": "Settings"
},
@@ -43,7 +30,6 @@
"tabs": {
"account": "Profile",
"audio": "Audio",
"general": "General",
"notifications": "Notifications"
"general": "General"
}
}
+7 -24
View File
@@ -70,10 +70,6 @@
"open": "Masquer les participants",
"closed": "Afficher les participants"
},
"transcript": {
"open": "Masquer l'assistant IA",
"closed": "Afficher l'assistant IA"
},
"support": "Support"
},
"options": {
@@ -82,7 +78,11 @@
"feedbacks": "Partager votre avis",
"settings": "Paramètres",
"username": "Choisir votre nom",
"effects": "Appliquer des effets"
"effects": "Appliquer des effets",
"transcript": {
"start": "Démarrer la transcription",
"stop": "Arrêter la transcription en cours"
}
}
},
"effects": {
@@ -101,32 +101,18 @@
"heading": {
"participants": "Participants",
"effects": "Effets",
"chat": "Messages dans l'appel",
"transcript": "Assistant IA"
"chat": "Messages dans l'appel"
},
"content": {
"participants": "les participants",
"effects": "les effets",
"chat": "les messages",
"transcript": "l'assistant IA"
"chat": "les messages"
},
"closeButton": "Masquer {{content}}"
},
"chat": {
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
},
"transcript": {
"start": {
"heading": "Démarrer l'assistant !",
"body": "L'assistant démarre automatiquement l'enregistrement sonore de votre réunion (limité à 1h). À la fin, vous recevrez un résumé clair et concis des échanges directement par e-mail.",
"button": "Démarrer"
},
"stop": {
"heading": "Enregistrement en cours …",
"body": "L'enregistrement de votre réunion est en cours. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
"button": "Arrêter l'enregistrement"
}
},
"rating": {
"submit": "Envoyer",
"question": "Que pensez-vous de la qualité de votre appel ?",
@@ -163,8 +149,5 @@
"raisedHands": "Mains levées",
"lowerParticipantHand": "Baisser la main de {{name}}",
"lowerParticipantsHand": "Baisser la main de tous les participants"
},
"recording": {
"label": "Enregistrement"
}
}
+1 -15
View File
@@ -19,19 +19,6 @@
},
"permissionsRequired": "Autorisations nécessaires"
},
"notifications": {
"heading": "Notifications sonores",
"label": "la notification sonore pour",
"actions": {
"disable": "Désactiver",
"enable": "Activer"
},
"items": {
"participantJoined": "Un nouveau participant",
"handRaised": "Une main levée",
"messageReceived": "Un message reçu"
}
},
"dialog": {
"heading": "Paramètres"
},
@@ -43,7 +30,6 @@
"tabs": {
"account": "Profile",
"audio": "Audio",
"general": "Général",
"notifications": "Notifications"
"general": "Général"
}
}
+2 -9
View File
@@ -5,14 +5,10 @@ import {
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
RACButtonsProps &
TooltipWrapperProps & {
// Use tooltip as description below the button.
description?: boolean
}
TooltipWrapperProps
export const Button = ({
tooltip,
@@ -26,10 +22,7 @@ export const Button = ({
<RACButton
className={buttonRecipe(variantProps)}
{...(componentProps as RACButtonsProps)}
>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
/>
</TooltipWrapper>
)
}
+2 -11
View File
@@ -2,14 +2,10 @@ import { Link, LinkProps } from 'react-aria-components'
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
LinkProps &
TooltipWrapperProps & {
// Use tooltip as description below the button.
description?: boolean
}
TooltipWrapperProps
export const LinkButton = ({
tooltip,
@@ -20,12 +16,7 @@ export const LinkButton = ({
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<Link className={buttonRecipe(variantProps)} {...componentProps}>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</Link>
<Link className={buttonRecipe(variantProps)} {...componentProps} />
</TooltipWrapper>
)
}
-69
View File
@@ -1,69 +0,0 @@
import {
Switch as RACSwitch,
SwitchProps as RACSwitchProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { StyledVariantProps } from '@/styled-system/types'
import { ReactNode } from 'react'
export const StyledSwitch = styled(RACSwitch, {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.571rem',
color: 'black',
forcedColorAdjust: 'none',
'& .indicator': {
width: '2.6rem',
height: '1.563rem',
border: '0.125rem solid',
borderColor: 'primary.800',
borderRadius: '1.143rem',
transition: 'all 200ms, outline 200ms',
_before: {
content: '""',
display: 'block',
margin: '0.125rem',
width: '1.063rem',
height: '1.063rem',
borderRadius: '1.063rem',
background: 'primary.800',
transition: 'all 200ms',
},
},
'&[data-selected] .indicator': {
borderColor: 'primary.800',
background: 'primary.800',
_before: {
background: 'white',
transform: 'translateX(100%)',
},
},
'&[data-disabled] .indicator': {
borderColor: 'primary.200',
background: 'transparent',
_before: {
background: 'primary.200',
},
},
'&[data-focus-visible] .indicator': {
outline: '2px solid!',
outlineColor: 'focusRing!',
outlineOffset: '2px!',
},
},
variants: {},
})
export type SwitchProps = StyledVariantProps<typeof StyledSwitch> &
RACSwitchProps & { children: ReactNode }
/**
* Styled RAC Switch.
*/
export const Switch = ({ children, ...props }: SwitchProps) => (
<StyledSwitch {...props}>
<div className="indicator" />
{children}
</StyledSwitch>
)
-8
View File
@@ -55,14 +55,6 @@ export const text = cva({
textAlign: 'inherit',
},
},
wrap: {
balance: {
textWrap: 'balance',
},
pretty: {
textWrap: 'pretty',
},
},
bold: {
true: {
fontWeight: 'bold',
+3 -17
View File
@@ -1,17 +1,9 @@
import {
ToggleButton as RACToggleButton,
ToggleButtonProps as RACToggleButtonProps,
ToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
export type ToggleButtonProps = RACToggleButtonProps &
ButtonRecipeProps &
TooltipWrapperProps & {
// Use tooltip as description below the button.
description?: boolean
}
/**
* React aria ToggleButton with our button styles, that can take a tooltip if needed
@@ -20,20 +12,14 @@ export const ToggleButton = ({
tooltip,
tooltipType,
...props
}: ToggleButtonProps) => {
}: ToggleButtonProps & ButtonRecipeProps & TooltipWrapperProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={buttonRecipe(variantProps)}
>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</RACToggleButton>
/>
</TooltipWrapper>
)
}
+4 -14
View File
@@ -1,5 +1,9 @@
import { type RecipeVariantProps, cva } from '@/styled-system/css'
export type ButtonRecipe = typeof buttonRecipe
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
export const buttonRecipe = cva({
base: {
display: 'flex',
@@ -218,16 +222,6 @@ export const buttonRecipe = cva({
shySelected: {
true: {},
},
description: {
true: {
flexDirection: 'column',
gap: '0.5rem',
'& span': {
fontSize: '13px',
textAlign: 'center',
},
},
},
// if the button is next to other ones to make a "button group", tell where the button is to handle radius
groupPosition: {
left: {
@@ -261,7 +255,3 @@ export const buttonRecipe = cva({
variant: 'primary',
},
})
export type ButtonRecipe = typeof buttonRecipe
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
-1
View File
@@ -29,4 +29,3 @@ export { ToggleButton } from './ToggleButton'
export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
export { TextArea } from './TextArea'
export { Switch } from './Switch'
-44
View File
@@ -1,44 +0,0 @@
import { proxy, subscribe } from 'valtio'
import { proxyMap } from 'valtio/utils'
import { deserializeToProxyMap, serializeProxyMap } from '@/utils/valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { NotificationType } from '@/features/notifications/NotificationType'
type State = {
soundNotifications: Map<NotificationType, boolean>
soundNotificationVolume: number
}
const DEFAULT_STATE: State = {
soundNotifications: proxyMap(
new Map([
[NotificationType.ParticipantJoined, true],
[NotificationType.HandRaised, true],
])
),
soundNotificationVolume: 0.1,
}
function getNotificationsState(): State {
try {
const stored = localStorage.getItem(STORAGE_KEYS.NOTIFICATIONS)
if (!stored) return DEFAULT_STATE
const parsed = JSON.parse(stored, deserializeToProxyMap)
return parsed || DEFAULT_STATE
} catch (error: unknown) {
console.error(
'[NotificationsStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const notificationsStore = proxy<State>(getNotificationsState())
subscribe(notificationsStore, () => {
localStorage.setItem(
STORAGE_KEYS.NOTIFICATIONS,
JSON.stringify(notificationsStore, serializeProxyMap)
)
})
-9
View File
@@ -29,12 +29,3 @@ body,
body:has(.lk-video-conference) #crisp-chatbox > div > a {
display: none !important;
}
@keyframes slide-full {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
-2
View File
@@ -1,2 +0,0 @@
export const GRIST_FORM =
'https://grist.numerique.gouv.fr/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4' as const
-6
View File
@@ -1,6 +0,0 @@
/**
* Object containing all localStorage keys used across the app
*/
export const STORAGE_KEYS = {
NOTIFICATIONS: 'app_notification_settings',
} as const
-20
View File
@@ -1,20 +0,0 @@
import { isMobileBrowser } from '@livekit/components-core'
import { useEffect, useState } from 'react'
export const useIsMobile = () => {
const [isMobile, setIsMobile] = useState(isMobileBrowser())
useEffect(() => {
const handleResize = () => {
setIsMobile(isMobileBrowser())
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
return isMobile
}
-28
View File
@@ -1,28 +0,0 @@
import { proxyMap } from 'valtio/utils'
/**
* Serializes Map objects into a JSON-friendly format while preserving valtio proxyMap compatibility
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function serializeProxyMap(_: string, value: any) {
if (value instanceof Map) {
return {
dataType: 'Map',
value: Array.from(value.entries()),
}
}
return value
}
/**
* Custom JSON reviver function for deserializing Map objects and wrapping them in valtio proxyMap
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function deserializeToProxyMap(_: string, value: any) {
if (typeof value === 'object' && value !== null) {
if (value.dataType === 'Map') {
return proxyMap(new Map(value.value))
}
}
return value
}
File diff suppressed because it is too large Load Diff
@@ -1,43 +0,0 @@
replicaCount: 1
terminationGracePeriodSeconds: 18000
egress:
log_level: debug
ws_url: ws://livekit-livekit-server:80
insecure: true
enable_chrome_sandbox: true
{{- with .Values.livekit.keys }}
{{- range $key, $value := . }}
api_key: {{ $key }}
api_secret: {{ $value }}
{{- end }}
{{- end }}
redis:
address: redis-master:6379
password: pass
s3:
access_key: meet
secret: password
region: local
bucket: meet-media-storage
endpoint: http://minio:9000
force_path_style: true
loadBalancer:
type: nginx
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- hosts:
- livekit-egress.127.0.0.1.nip.io
secretName: livekit-egress-dinum-cert
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
nodeSelector: {}
resources: {}
@@ -1,40 +0,0 @@
replicaCount: 1
terminationGracePeriodSeconds: 18000
livekit:
log_level: debug
rtc:
use_external_ip: false
port_range_start: 50000
port_range_end: 60000
tcp_port: 7881
redis:
address: redis-master:6379
password: pass
keys:
turn:
enabled: true
udp_port: 443
domain: livekit.127.0.0.1.nip.io
loadBalancerAnnotations: {}
loadBalancer:
type: nginx
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
tls:
- hosts:
- livekit.127.0.0.1.nip.io
secretName: livekit-dinum-cert
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 5
targetCPUUtilizationPercentage: 60
nodeSelector: {}
resources: {}
@@ -1,188 +0,0 @@
image:
repository: localhost:5001/meet-backend
pullPolicy: Always
tag: "latest"
backend:
replicas: 1
envVars:
DJANGO_CSRF_TRUSTED_ORIGINS: https://meet.127.0.0.1.nip.io,http://meet.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Production
DJANGO_ALLOWED_HOSTS: meet.127.0.0.1.nip.io
DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }}
DJANGO_SETTINGS_MODULE: meet.settings
DJANGO_SILENCED_SYSTEM_CHECKS: security.W004, security.W008
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID:
secretKeyRef:
name: backend
key: OIDC_RP_CLIENT_ID
OIDC_RP_CLIENT_SECRET:
secretKeyRef:
name: backend
key: OIDC_RP_CLIENT_SECRET
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
OIDC_VERIFY_SSL: False
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
DB_HOST: postgres-postgresql
DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
{{- with .Values.livekit.keys }}
{{- range $key, $value := . }}
LIVEKIT_API_SECRET: {{ $value }}
LIVEKIT_API_KEY: {{ $key }}
{{- end }}
{{- end }}
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
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: False
RECORDING_STORAGE_EVENT_ENABLE: True
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN: password
migrate:
command:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
- "gunicorn"
- "-c"
- "/usr/local/etc/gunicorn/meet.py"
- "meet.wsgi:application"
- "--reload"
createsuperuser:
command:
- "/bin/sh"
- "-c"
- |
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
frontend:
envVars:
VITE_PORT: 8080
VITE_HOST: 0.0.0.0
VITE_API_BASE_URL: https://meet.127.0.0.1.nip.io/
replicas: 1
image:
repository: localhost:5001/meet-frontend
pullPolicy: Always
tag: "latest"
ingress:
enabled: true
host: meet.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: meet.127.0.0.1.nip.io
posthog:
ingress:
enabled: false
ingressAssets:
enabled: false
summary:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
pullPolicy: Always
tag: "latest"
command:
- "uvicorn"
- "summary.main:app"
- "--host"
- "0.0.0.0"
- "--port"
- "8000"
- "--reload"
celery:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
WEBHOOK_API_TOKEN: password
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
image:
repository: localhost:5001/meet-summary
pullPolicy: Always
tag: "latest"
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
@@ -1,10 +0,0 @@
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
livekit:
keys:
devkey: secret
livekitApi:
key: devkey
secret: secret
oidc:
clientId: meet
clientSecret: ThisIsAnExampleKeyForDevPurposeOnly
@@ -17,10 +17,6 @@ livekit:
udp_port: 443
domain: livekit.127.0.0.1.nip.io
loadBalancerAnnotations: {}
webhook:
api_key: devkey
urls:
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/livekit-webhook/
loadBalancer:
+2 -3
View File
@@ -61,7 +61,6 @@ backend:
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN: password
PASSPHRASE_ENCRYPTION_KEY: lT3cX5dzFhCe-9xNjXUiTCX00r2ZgHgGUJKO66x-QIo=
migrate:
@@ -126,7 +125,7 @@ summary:
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr:32222/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
@@ -159,7 +158,7 @@ celery:
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr:32222/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.12"
tag: "v0.1.10"
backend:
migrateJobAnnotations:
@@ -116,17 +116,6 @@ backend:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
RECORDING_ENABLE: True
RECORDING_STORAGE_EVENT_ENABLE: True
RECORDING_STORAGE_EVENT_TOKEN:
secretKeyRef:
name: backend
key: RECORDING_STORAGE_EVENT_TOKEN
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN:
secretKeyRef:
name: summary
key: APP_API_TOKEN
createsuperuser:
command:
@@ -140,7 +129,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.12"
tag: "v0.1.10"
ingress:
enabled: true
@@ -178,113 +167,7 @@ posthog:
nginx.ingress.kubernetes.io/backend-protocol: https
summary:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN:
secretKeyRef:
name: summary
key: APP_API_TOKEN
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: url
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
OPENAI_API_KEY:
secretKeyRef:
name: summary
key: OPENAI_API_KEY
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN:
secretKeyRef:
name: summary
key: WEBHOOK_API_TOKEN
WEBHOOK_URL: https://docs.numerique.gouv.fr/api/v1.0/documents/create-for-owner/
CELERY_BROKER_URL:
secretKeyRef:
name: redis-summary.redis.libre.sh
key: url
CELERY_RESULT_BACKEND:
secretKeyRef:
name: redis-summary.redis.libre.sh
key: url
image:
repository: lasuite/meet-summary
pullPolicy: Always
tag: "v0.1.12"
replicas: 0
celery:
replicas: 1
envVars:
APP_NAME: summary-microservice
APP_API_TOKEN:
secretKeyRef:
name: summary
key: APP_API_TOKEN
AWS_S3_ENDPOINT_URL:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: endpoint
AWS_S3_ACCESS_KEY_ID:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: accessKey
AWS_S3_SECRET_ACCESS_KEY:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: secretKey
AWS_STORAGE_BUCKET_NAME:
secretKeyRef:
name: meet-media-storage.bucket.libre.sh
key: bucket
AWS_S3_REGION_NAME: local
OPENAI_API_KEY:
secretKeyRef:
name: summary
key: OPENAI_API_KEY
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN:
secretKeyRef:
name: summary
key: WEBHOOK_API_TOKEN
WEBHOOK_URL: https://docs.numerique.gouv.fr/api/v1.0/documents/create-for-owner/
CELERY_BROKER_URL:
secretKeyRef:
name: redis-summary.redis.libre.sh
key: url
CELERY_RESULT_BACKEND:
secretKeyRef:
name: redis-summary.redis.libre.sh
key: url
SENTRY_IS_ENABLED: True
SENTRY_DSN: https://5aead03f03505da5130af6d642c42faf@sentry.incubateur.net/202
image:
repository: lasuite/meet-summary
pullPolicy: Always
tag: "v0.1.12"
command:
- "celery"
- "-A"
- "summary.core.celery_worker"
- "worker"
- "--pool=solo"
- "--loglevel=info"
replicas: 0
@@ -216,14 +216,14 @@ summary:
secretKeyRef:
name: summary
key: OPENAI_API_KEY
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr:32222/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN:
secretKeyRef:
name: summary
key: WEBHOOK_API_TOKEN
WEBHOOK_URL: https://impress-staging.beta.numerique.gouv.fr/api/v1.0/documents/create-for-owner/
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL:
secretKeyRef:
name: redis-summary.redis.libre.sh
@@ -267,14 +267,11 @@ celery:
secretKeyRef:
name: summary
key: OPENAI_API_KEY
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
WEBHOOK_API_TOKEN:
secretKeyRef:
name: summary
key: WEBHOOK_API_TOKEN
WEBHOOK_URL: https://impress-staging.beta.numerique.gouv.fr/api/v1.0/documents/create-for-owner/
WEBHOOK_URL: https://www.mock-impress.com/webhook/
CELERY_BROKER_URL:
secretKeyRef:
name: redis-summary.redis.libre.sh
+6 -60
View File
@@ -1,8 +1,4 @@
environments:
dev-keycloak:
values:
- version: 0.0.1
- env.d/{{ .Environment.Name }}/values.secrets.yaml
dev:
values:
- version: 0.0.1
@@ -36,8 +32,7 @@ repositories:
releases:
- name: postgres
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
chart: bitnami/postgresql
version: 13.1.5
@@ -50,50 +45,9 @@ releases:
enabled: true
autoGenerated: true
- name: keycloak
installed: {{ eq .Environment.Name "dev-keycloak" | toYaml }}
missingFileHandler: Warn
namespace: {{ .Namespace }}
chart: bitnami/keycloak
version: 17.3.6
values:
- postgresql:
auth:
username: keycloak
password: keycloak
database: keycloak
- extraEnvVars:
- name: KEYCLOAK_EXTRA_ARGS
value: "--import-realm"
- name: KC_HOSTNAME_URL
value: https://keycloak.127.0.0.1.nip.io
- extraVolumes:
- name: import
configMap:
name: meet-keycloak
- extraVolumeMounts:
- name: import
mountPath: /opt/bitnami/keycloak/data/import/
- auth:
adminUser: su
adminPassword: su
- proxy: edge
- ingress:
enabled: true
hostname: keycloak.127.0.0.1.nip.io
- extraDeploy:
- apiVersion: v1
kind: ConfigMap
metadata:
name: meet-keycloak
data:
meet.json: |
{{ readFile "../../docker/auth/realm.json" | replace "http://localhost:3200" "https://meet.127.0.0.1.nip.io" | indent 14 }}
- name: minio
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
missingFileHandler: Warn
chart: bitnami/minio
version: 12.10.10
values:
@@ -121,8 +75,7 @@ releases:
name: mkcert
- name: redis
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
chart: bitnami/redis
version: 18.19.2
@@ -132,8 +85,7 @@ releases:
architecture: standalone
- name: extra
installed: {{ not (regexMatch "^dev.*" .Environment.Name) | toYaml }}
missingFileHandler: Warn
installed: {{ ne .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
chart: ./extra
secrets:
@@ -148,32 +100,26 @@ releases:
- name: meet
version: {{ .Values.version }}
namespace: {{ .Namespace }}
missingFileHandler: Warn
chart: ./meet
values:
- env.d/{{ .Environment.Name }}/values.meet.yaml.gotmpl
- env.d/{{ .Environment.Name }}/values.secrets.yaml
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
- name: livekit
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
chart: livekit/livekit-server
values:
- env.d/{{ .Environment.Name }}/values.livekit.yaml.gotmpl
- env.d/{{ .Environment.Name }}/values.secrets.yaml
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
- name: livekit-egress
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
missingFileHandler: Warn
installed: {{ eq .Environment.Name "dev" | toYaml }}
namespace: {{ .Namespace }}
chart: livekit/egress
values:
- env.d/{{ .Environment.Name }}/values.egress.yaml.gotmpl
- env.d/{{ .Environment.Name }}/values.secrets.yaml
secrets:
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
+4 -5
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.12",
"version": "0.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.12",
"version": "0.1.10",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
@@ -344,9 +344,8 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"version": "7.0.3",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.12",
"version": "0.1.10",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+4 -4
View File
@@ -8,16 +8,16 @@ dependencies = [
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"celery==5.4.0",
"redis==5.2.1",
"minio==7.2.13",
"openai==1.58.1",
"redis==4.5.4",
"minio==7.2.9",
"openai==1.55.3",
"requests==2.32.3",
"sentry-sdk[fastapi, celery]==2.19.0",
]
[project.optional-dependencies]
dev = [
"ruff==0.8.4",
"ruff==0.7.4",
]
[build-system]
+1 -3
View File
@@ -125,10 +125,8 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
summary = summary_response.choices[0].message.content
logger.debug("Summary: \n %s", summary)
# fixme - generate a title using LLM
data = {
"title": "Votre résumé",
"content": summary,
"summary": summary,
"email": email,
"sub": sub,
}