mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 21:01:55 +00:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d9ac96d2b | |||
| 5428aec43e | |||
| a2408aebff | |||
| 3e3ca6a87b | |||
| 9830940d61 | |||
| dd7e3a7c44 | |||
| 1cf26eec19 | |||
| 83d4028e84 | |||
| ac65404ad6 | |||
| 4c0230d537 | |||
| 7309df4115 | |||
| 18b2dfc497 | |||
| 3282da7c56 | |||
| 7f8a6e8685 | |||
| 4232c0a303 | |||
| a5454e48b7 | |||
| 591706f363 | |||
| 1ea84b6e6c | |||
| c0d101a326 | |||
| 64cfcb6c0f | |||
| cdd69b741a | |||
| f5a87cc210 | |||
| 4d4ddb9ee8 | |||
| 70dbf94f7b | |||
| 0ad37ee6de | |||
| ed4f7dcf6c | |||
| c54773008c | |||
| 6848321bcc | |||
| f161a5cf6a | |||
| 51270571bc | |||
| c6fdeaf1e9 | |||
| f1959cbb3a | |||
| 84cea2f658 | |||
| db1fdb9871 | |||
| a8618239d1 | |||
| 0eb283b75a | |||
| 0104cabc5e | |||
| dbfba564c5 | |||
| 4830206bb2 | |||
| e4f30f926c | |||
| fc61f58596 | |||
| e0021dbb80 | |||
| 83914f8307 | |||
| be54709598 | |||
| 5b76ea492b | |||
| 46934a84d1 | |||
| deb9ab1a1d | |||
| d0fd16d7d2 | |||
| 0b8181e5ce | |||
| 8b2365d5f9 | |||
| 2dd16d1f40 | |||
| fa9484b630 | |||
| 1e0e495cd8 | |||
| c270299179 | |||
| d9a84e5f0f | |||
| 0f64d3cf3a | |||
| d1e008a844 | |||
| 3be5a5afc6 | |||
| 31468a5e7c | |||
| b342b9d526 | |||
| f7e7c3ba22 | |||
| 3902b02691 | |||
| 7ce4390740 | |||
| 94d18cffe4 | |||
| ad0c3eea66 | |||
| 459bbf65a8 |
@@ -305,3 +305,6 @@ start-tilt: ## start the kubernetes cluster using kind
|
|||||||
tilt up -f ./bin/Tiltfile
|
tilt up -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.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
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ $ make build-k8s-cluster
|
|||||||
Once the Kubernetes cluster is ready, start the application stack locally:
|
Once the Kubernetes cluster is ready, start the application stack locally:
|
||||||
```shell
|
```shell
|
||||||
$ make start-tilt
|
$ 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.
|
These commands set up and run your application environment using Tilt for local Kubernetes development.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ docker_build(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e dev template .'))
|
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||||
|
|
||||||
migration = '''
|
migration = '''
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
+405
-405
File diff suppressed because it is too large
Load Diff
+1
-1
Submodule secrets updated: 142e7a70b1...2ba12db71d
@@ -130,17 +130,18 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
del output["configuration"]
|
del output["configuration"]
|
||||||
|
|
||||||
if role is not None or instance.is_public:
|
if role is not None or instance.is_public:
|
||||||
slug = f"{instance.id!s}"
|
room_id = f"{instance.id!s}"
|
||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
|
|
||||||
output["livekit"] = {
|
output["livekit"] = {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"room": slug,
|
"room": room_id,
|
||||||
"token": utils.generate_token(
|
"token": utils.generate_token(
|
||||||
room=slug, user=request.user, username=username
|
room=room_id, user=request.user, username=username
|
||||||
),
|
),
|
||||||
|
"passphrase": utils.get_cached_passphrase(room_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
output["is_administrable"] = is_admin
|
output["is_administrable"] = is_admin
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ from core.recording.worker.mediator import (
|
|||||||
|
|
||||||
from . import permissions, serializers
|
from . import permissions, serializers
|
||||||
|
|
||||||
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
# pylint: disable=too-many-ancestors
|
# pylint: disable=too-many-ancestors
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -210,6 +212,10 @@ class RoomViewSet(
|
|||||||
Allow unregistered rooms when activated.
|
Allow unregistered rooms when activated.
|
||||||
For unregistered rooms we only return a null id and the livekit room and token.
|
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:
|
try:
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
except Http404:
|
except Http404:
|
||||||
@@ -343,6 +349,36 @@ class RoomViewSet(
|
|||||||
{"message": f"Recording stopped for room {room.slug}."}
|
{"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:
|
class ResourceAccessListModelMixin:
|
||||||
"""List mixin for resource access API."""
|
"""List mixin for resource access API."""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Authentication Backends for the Meet core app."""
|
"""Authentication Backends for the Meet core app."""
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -10,6 +10,11 @@ from mozilla_django_oidc.auth import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
from core.services.marketing_service import (
|
||||||
|
ContactCreationError,
|
||||||
|
ContactData,
|
||||||
|
get_marketing_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||||
@@ -86,6 +91,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
|||||||
password="!", # noqa: S106
|
password="!", # noqa: S106
|
||||||
**claims,
|
**claims,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||||
|
self.signup_to_marketing_email(email)
|
||||||
|
|
||||||
elif not user:
|
elif not user:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -96,6 +105,26 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
|||||||
|
|
||||||
return user
|
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):
|
def get_existing_user(self, sub, email):
|
||||||
"""Fetch existing user by sub or email."""
|
"""Fetch existing user by sub or email."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
|||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||||
"""Custom callback view for handling the silent loging flow."""
|
"""Custom callback view for handling the silent login flow."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def failure_url(self):
|
def failure_url(self):
|
||||||
@@ -162,7 +162,7 @@ class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
|||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
||||||
"""Custom authentication view for handling the silent loging flow."""
|
"""Custom authentication view for handling the silent login flow."""
|
||||||
|
|
||||||
def get_extra_params(self, request):
|
def get_extra_params(self, request):
|
||||||
"""Handle 'prompt' extra parameter for the silent login flow
|
"""Handle 'prompt' extra parameter for the silent login flow
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "meet_user"
|
db_table = "meet_user"
|
||||||
|
ordering = ("-created_at",)
|
||||||
verbose_name = _("user")
|
verbose_name = _("user")
|
||||||
verbose_name_plural = _("users")
|
verbose_name_plural = _("users")
|
||||||
|
|
||||||
@@ -304,6 +305,7 @@ class ResourceAccess(BaseModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = "meet_resource_access"
|
db_table = "meet_resource_access"
|
||||||
|
ordering = ("-created_at",)
|
||||||
verbose_name = _("Resource access")
|
verbose_name = _("Resource access")
|
||||||
verbose_name_plural = _("Resource accesses")
|
verbose_name_plural = _("Resource accesses")
|
||||||
constraints = [
|
constraints = [
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""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,12 +1,15 @@
|
|||||||
"""Unit tests for the Authentication Backends."""
|
"""Unit tests for the Authentication Backends."""
|
||||||
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
from core.authentication.backends import OIDCAuthenticationBackend
|
from core.authentication.backends import OIDCAuthenticationBackend
|
||||||
from core.factories import UserFactory
|
from core.factories import UserFactory
|
||||||
|
from core.services import marketing_service
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -412,3 +415,139 @@ def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
|
|||||||
user.refresh_from_db()
|
user.refresh_from_db()
|
||||||
|
|
||||||
assert user.email == "john.doe@example.com"
|
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")
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@@ -66,7 +66,7 @@ def test_api_users_list_query_email():
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
user_ids = [user["id"] for user in response.json()["results"]]
|
user_ids = [user["id"] for user in response.json()["results"]]
|
||||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_retrieve_me_anonymous():
|
def test_api_users_retrieve_me_anonymous():
|
||||||
|
|||||||
@@ -14,6 +14,47 @@ from django.conf import settings
|
|||||||
|
|
||||||
from livekit.api import AccessToken, VideoGrants
|
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:
|
def generate_color(identity: str) -> str:
|
||||||
"""Generates a consistent HSL color based on a given identity string.
|
"""Generates a consistent HSL color based on a given identity string.
|
||||||
|
|||||||
@@ -324,8 +324,10 @@ class Base(Configuration):
|
|||||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
||||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||||
OIDC_CREATE_USER = values.BooleanValue(
|
OIDC_CREATE_USER = values.BooleanValue(
|
||||||
default=True,
|
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||||
environ_name="OIDC_CREATE_USER",
|
)
|
||||||
|
OIDC_VERIFY_SSL = values.BooleanValue(
|
||||||
|
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
|
||||||
)
|
)
|
||||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -455,6 +457,34 @@ class Base(Configuration):
|
|||||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
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
|
# pylint: disable=invalid-name
|
||||||
@property
|
@property
|
||||||
def ENVIRONMENT(self):
|
def ENVIRONMENT(self):
|
||||||
@@ -501,8 +531,10 @@ class Base(Configuration):
|
|||||||
release=get_release(),
|
release=get_release(),
|
||||||
integrations=[DjangoIntegration()],
|
integrations=[DjangoIntegration()],
|
||||||
)
|
)
|
||||||
with sentry_sdk.configure_scope() as scope:
|
|
||||||
scope.set_extra("application", "backend")
|
# Add the application name to the Sentry scope
|
||||||
|
scope = sentry_sdk.get_global_scope()
|
||||||
|
scope.set_tag("application", "backend")
|
||||||
|
|
||||||
|
|
||||||
class Build(Base):
|
class Build(Base):
|
||||||
|
|||||||
+16
-15
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "0.1.10"
|
version = "0.1.12"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -25,20 +25,21 @@ license = { file = "LICENSE" }
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.35.68",
|
"boto3==1.35.90",
|
||||||
"Brotli==1.1.0",
|
"Brotli==1.1.0",
|
||||||
|
"brevo-python==1.1.2",
|
||||||
"celery[redis]==5.4.0",
|
"celery[redis]==5.4.0",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.6.0",
|
"django-cors-headers==4.6.0",
|
||||||
"django-countries==7.6.1",
|
"django-countries==7.6.1",
|
||||||
"django-parler==2.3",
|
"django-parler==2.3",
|
||||||
"redis==5.2.0",
|
"redis==5.2.1",
|
||||||
"django-redis==5.4.0",
|
"django-redis==5.4.0",
|
||||||
"django-storages[s3]==1.14.4",
|
"django-storages[s3]==1.14.4",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django==5.1.3",
|
"django==5.1.4",
|
||||||
"djangorestframework==3.15.2",
|
"djangorestframework==3.15.2",
|
||||||
"drf_spectacular==0.27.2",
|
"drf_spectacular==0.28.0",
|
||||||
"dockerflow==2024.4.2",
|
"dockerflow==2024.4.2",
|
||||||
"easy_thumbnails==2.10",
|
"easy_thumbnails==2.10",
|
||||||
"factory_boy==3.3.1",
|
"factory_boy==3.3.1",
|
||||||
@@ -48,16 +49,16 @@ dependencies = [
|
|||||||
"markdown==3.7",
|
"markdown==3.7",
|
||||||
"nested-multipart-parser==1.5.0",
|
"nested-multipart-parser==1.5.0",
|
||||||
"psycopg[binary]==3.2.3",
|
"psycopg[binary]==3.2.3",
|
||||||
"PyJWT==2.10.0",
|
"PyJWT==2.10.1",
|
||||||
"python-frontmatter==1.1.0",
|
"python-frontmatter==1.1.0",
|
||||||
"requests==2.32.3",
|
"requests==2.32.3",
|
||||||
"sentry-sdk==2.19.0",
|
"sentry-sdk==2.19.2",
|
||||||
"url-normalize==1.4.3",
|
"url-normalize==1.4.3",
|
||||||
"WeasyPrint>=60.2",
|
"WeasyPrint>=60.2",
|
||||||
"whitenoise==6.8.2",
|
"whitenoise==6.8.2",
|
||||||
"mozilla-django-oidc==4.0.1",
|
"mozilla-django-oidc==4.0.1",
|
||||||
"livekit-api==0.8.0",
|
"livekit-api==0.8.1",
|
||||||
"aiohttp==3.11.7",
|
"aiohttp==3.11.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -69,20 +70,20 @@ dependencies = [
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==3.2.3",
|
"django-extensions==3.2.3",
|
||||||
"drf-spectacular-sidecar==2024.11.1",
|
"drf-spectacular-sidecar==2024.12.1",
|
||||||
"freezegun==1.5.1",
|
"freezegun==1.5.1",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==8.29.0",
|
"ipython==8.31.0",
|
||||||
"pyfakefs==5.7.1",
|
"pyfakefs==5.7.3",
|
||||||
"pylint-django==2.6.1",
|
"pylint-django==2.6.1",
|
||||||
"pylint==3.3.1",
|
"pylint==3.3.3",
|
||||||
"pytest-cov==6.0.0",
|
"pytest-cov==6.0.0",
|
||||||
"pytest-django==4.9.0",
|
"pytest-django==4.9.0",
|
||||||
"pytest==8.3.3",
|
"pytest==8.3.4",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.6.1",
|
"pytest-xdist==3.6.1",
|
||||||
"responses==0.25.3",
|
"responses==0.25.3",
|
||||||
"ruff==0.8.0",
|
"ruff==0.8.4",
|
||||||
"types-requests==2.32.0.20241016",
|
"types-requests==2.32.0.20241016",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "2.6.9",
|
"@livekit/components-react": "2.6.9",
|
||||||
"@livekit/components-styles": "1.1.4",
|
"@livekit/components-styles": "1.1.4",
|
||||||
@@ -7702,9 +7702,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.7",
|
"version": "3.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
|
||||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
|
|||||||
@@ -116,10 +116,10 @@ const config: Config = {
|
|||||||
'80%': { transform: 'rotate(20deg)' },
|
'80%': { transform: 'rotate(20deg)' },
|
||||||
'100%': { transform: 'rotate(0)' },
|
'100%': { transform: 'rotate(0)' },
|
||||||
},
|
},
|
||||||
pulse_mic: {
|
pulse_background: {
|
||||||
'0%': { color: 'primary', opacity: '1' },
|
'0%': { opacity: '1' },
|
||||||
'50%': { color: 'primary', opacity: '0.8' },
|
'50%': { opacity: '0.65' },
|
||||||
'100%': { color: 'primary', opacity: '1' },
|
'100%': { opacity: '1' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { css } from '@/styled-system/css'
|
|||||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Text, A } from '@/primitives'
|
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 = () => {
|
export const FeedbackBanner = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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()) {
|
if (isMobileBrowser()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
triggerNotificationSound(NotificationType.Joined)
|
triggerNotificationSound(NotificationType.ParticipantJoined)
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
{
|
{
|
||||||
participant,
|
participant,
|
||||||
type: NotificationType.Joined,
|
type: NotificationType.ParticipantJoined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
@@ -67,7 +67,7 @@ export const MainNotificationToast = () => {
|
|||||||
const existingToast = toastQueue.visibleToasts.find(
|
const existingToast = toastQueue.visibleToasts.find(
|
||||||
(toast) =>
|
(toast) =>
|
||||||
toast.content.participant === participant &&
|
toast.content.participant === participant &&
|
||||||
toast.content.type === NotificationType.Raised
|
toast.content.type === NotificationType.HandRaised
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
||||||
@@ -76,11 +76,11 @@ export const MainNotificationToast = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
||||||
triggerNotificationSound(NotificationType.Raised)
|
triggerNotificationSound(NotificationType.HandRaised)
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
{
|
{
|
||||||
participant,
|
participant,
|
||||||
type: NotificationType.Raised,
|
type: NotificationType.HandRaised,
|
||||||
},
|
},
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export enum NotificationType {
|
export enum NotificationType {
|
||||||
Joined = 'joined',
|
ParticipantJoined = 'participantJoined',
|
||||||
Default = 'default',
|
HandRaised = 'handRaised',
|
||||||
Raised = 'raised',
|
// todo - implement message received notification
|
||||||
Lowered = 'lowered',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export function ToastRegion({ state, ...props }: ToastRegionProps) {
|
|||||||
return (
|
return (
|
||||||
<div {...regionProps} ref={ref} className="toast-region">
|
<div {...regionProps} ref={ref} className="toast-region">
|
||||||
{state.visibleToasts.map((toast) => {
|
{state.visibleToasts.map((toast) => {
|
||||||
if (toast.content?.type === NotificationType.Joined) {
|
if (toast.content?.type === NotificationType.ParticipantJoined) {
|
||||||
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||||
}
|
}
|
||||||
if (toast.content?.type === NotificationType.Raised) {
|
if (toast.content?.type === NotificationType.HandRaised) {
|
||||||
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||||
}
|
}
|
||||||
return <Toast key={toast.key} toast={toast} state={state} />
|
return <Toast key={toast.key} toast={toast} state={state} />
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import useSound from 'use-sound'
|
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
|
// fixme - handle dynamic audio output changes
|
||||||
export const useNotificationSound = () => {
|
export const useNotificationSound = () => {
|
||||||
|
const notificationsSnap = useSnapshot(notificationsStore)
|
||||||
const [play] = useSound('./sounds/notifications.mp3', {
|
const [play] = useSound('./sounds/notifications.mp3', {
|
||||||
sprite: {
|
sprite: {
|
||||||
joined: [0, 1150],
|
participantJoined: [0, 1150],
|
||||||
raised: [1400, 180],
|
handRaised: [1400, 180],
|
||||||
message: [1580, 300],
|
messageReceived: [1580, 300],
|
||||||
waiting: [2039, 710],
|
waiting: [2039, 710],
|
||||||
success: [2740, 1304],
|
success: [2740, 1304],
|
||||||
},
|
},
|
||||||
|
volume: notificationsSnap.soundNotificationVolume,
|
||||||
})
|
})
|
||||||
const triggerNotificationSound = (type: string) => {
|
const triggerNotificationSound = (type: NotificationType) => {
|
||||||
play({ id: type })
|
const isSoundEnabled = notificationsSnap.soundNotifications.get(type)
|
||||||
|
if (isSoundEnabled) play({ id: type })
|
||||||
}
|
}
|
||||||
return { triggerNotificationSound }
|
return { triggerNotificationSound }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ApiRoom = {
|
|||||||
url: string
|
url: string
|
||||||
room: string
|
room: string
|
||||||
token: string
|
token: string
|
||||||
|
passphrase: string
|
||||||
}
|
}
|
||||||
configuration?: {
|
configuration?: {
|
||||||
[key: string]: string | number | boolean
|
[key: string]: string | number | boolean
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
|
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
|
||||||
import { Room, RoomOptions } from 'livekit-client'
|
import {
|
||||||
|
Room,
|
||||||
|
RoomOptions,
|
||||||
|
ExternalE2EEKeyProvider,
|
||||||
|
DeviceUnsupportedError,
|
||||||
|
} from 'livekit-client'
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
@@ -17,6 +22,9 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
import { css } from '@/styled-system/css'
|
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 = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
userConfig,
|
userConfig,
|
||||||
@@ -63,20 +71,85 @@ export const Conference = ({
|
|||||||
retry: false,
|
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 roomOptions = useMemo((): RoomOptions => {
|
||||||
|
const worker = getWorker()
|
||||||
|
const keyProvider = getKeyProvider()
|
||||||
|
|
||||||
|
// todo - explain why
|
||||||
|
const videoCodec = e2eeEnabled ? undefined : 'vp9'
|
||||||
|
const e2ee = e2eeEnabled ? { keyProvider, worker } : undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
adaptiveStream: true,
|
||||||
|
dynacast: true,
|
||||||
|
publishDefaults: {
|
||||||
|
// todo - explain why
|
||||||
|
red: !e2eeEnabled,
|
||||||
|
videoCodec,
|
||||||
|
},
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||||
},
|
},
|
||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
deviceId: userConfig.audioDeviceId ?? undefined,
|
deviceId: userConfig.audioDeviceId ?? undefined,
|
||||||
},
|
},
|
||||||
|
e2ee,
|
||||||
}
|
}
|
||||||
// do not rely on the userConfig object directly as its reference may change on every render
|
// do not rely on the userConfig object directly as its reference may change on every render
|
||||||
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
||||||
|
|
||||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
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 [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
@@ -97,6 +170,10 @@ export const Conference = ({
|
|||||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEncryptionError = () => {
|
||||||
|
console.log('error')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
||||||
<Screen header={false} footer={false}>
|
<Screen header={false} footer={false}>
|
||||||
@@ -104,13 +181,14 @@ export const Conference = ({
|
|||||||
room={room}
|
room={room}
|
||||||
serverUrl={data?.livekit?.url}
|
serverUrl={data?.livekit?.url}
|
||||||
token={data?.livekit?.token}
|
token={data?.livekit?.token}
|
||||||
connect={true}
|
connect={e2eeSetupComplete}
|
||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={userConfig.videoEnabled}
|
video={userConfig.videoEnabled}
|
||||||
connectOptions={connectOptions}
|
connectOptions={connectOptions}
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
|
onEncryptionError={handleEncryptionError}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<VideoConference />
|
||||||
{showInviteDialog && (
|
{showInviteDialog && (
|
||||||
|
|||||||
@@ -121,7 +121,13 @@ const OpenFeedback = ({
|
|||||||
>
|
>
|
||||||
{t('submit')}
|
{t('submit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button invisible size="sm" fullWidth onPress={onNext}>
|
<Button
|
||||||
|
invisible
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
onPress={onNext}
|
||||||
|
>
|
||||||
{t('skip')}
|
{t('skip')}
|
||||||
</Button>
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import {
|
|||||||
VideoTrack,
|
VideoTrack,
|
||||||
TrackRefContext,
|
TrackRefContext,
|
||||||
ParticipantContextIfNeeded,
|
ParticipantContextIfNeeded,
|
||||||
|
useIsSpeaking,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import React from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
isTrackReference,
|
isTrackReference,
|
||||||
isTrackReferencePinned,
|
isTrackReferencePinned,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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,6 +10,7 @@ import { useSidePanel } from '../hooks/useSidePanel'
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { Effects } from './Effects'
|
import { Effects } from './Effects'
|
||||||
import { Chat } from '../prefabs/Chat'
|
import { Chat } from '../prefabs/Chat'
|
||||||
|
import { Transcript } from './Transcript'
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -106,6 +107,7 @@ export const SidePanel = () => {
|
|||||||
isEffectsOpen,
|
isEffectsOpen,
|
||||||
isChatOpen,
|
isChatOpen,
|
||||||
isSidePanelOpen,
|
isSidePanelOpen,
|
||||||
|
isTranscriptOpen,
|
||||||
} = useSidePanel()
|
} = useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||||
|
|
||||||
@@ -127,6 +129,9 @@ export const SidePanel = () => {
|
|||||||
<Panel isOpen={isChatOpen}>
|
<Panel isOpen={isChatOpen}>
|
||||||
<Chat />
|
<Chat />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
<Panel isOpen={isTranscriptOpen}>
|
||||||
|
<Transcript />
|
||||||
|
</Panel>
|
||||||
</StyledSidePanel>
|
</StyledSidePanel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
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,8 +5,12 @@ import { css } from '@/styled-system/css'
|
|||||||
import { ToggleButton } from '@/primitives'
|
import { ToggleButton } from '@/primitives'
|
||||||
import { chatStore } from '@/stores/chat'
|
import { chatStore } from '@/stores/chat'
|
||||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||||
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
|
||||||
export const ChatToggle = () => {
|
export const ChatToggle = ({
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}: Partial<ToggleButtonProps>) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
|
||||||
|
|
||||||
const chatSnap = useSnapshot(chatStore)
|
const chatSnap = useSnapshot(chatStore)
|
||||||
@@ -27,8 +31,12 @@ export const ChatToggle = () => {
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isChatOpen}
|
isSelected={isChatOpen}
|
||||||
onPress={() => toggleChat()}
|
onPress={(e) => {
|
||||||
|
toggleChat()
|
||||||
|
onPress?.(e)
|
||||||
|
}}
|
||||||
data-attr={`controls-chat-${tooltipLabel}`}
|
data-attr={`controls-chat-${tooltipLabel}`}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<RiChat1Line />
|
<RiChat1Line />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
|
|||||||
+2
-11
@@ -1,16 +1,11 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiMore2Line } from '@remixicon/react'
|
import { RiMore2Line } from '@remixicon/react'
|
||||||
import { Button, Menu } from '@/primitives'
|
import { Button, Menu } from '@/primitives'
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { OptionsMenuItems } from '@/features/rooms/livekit/components/controls/Options/OptionsMenuItems'
|
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 = () => {
|
export const OptionsButton = () => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const [dialogOpen, setDialogOpen] = useState<DialogState>(null)
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu>
|
<Menu>
|
||||||
@@ -22,12 +17,8 @@ export const OptionsButton = () => {
|
|||||||
>
|
>
|
||||||
<RiMore2Line />
|
<RiMore2Line />
|
||||||
</Button>
|
</Button>
|
||||||
<OptionsMenuItems onOpenDialog={setDialogOpen} />
|
<OptionsMenuItems />
|
||||||
</Menu>
|
</Menu>
|
||||||
<SettingsDialogExtended
|
|
||||||
isOpen={dialogOpen === 'settings'}
|
|
||||||
onOpenChange={(v) => !v && setDialogOpen(null)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-11
@@ -5,21 +5,17 @@ import {
|
|||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
|
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Dispatch, SetStateAction } from 'react'
|
|
||||||
import { DialogState } from './OptionsButton'
|
|
||||||
import { Separator } from '@/primitives/Separator'
|
import { Separator } from '@/primitives/Separator'
|
||||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||||
import { TranscriptMenuItem } from './TranscriptMenuItem'
|
import { useSettingsDialog } from '../SettingsDialogContext'
|
||||||
|
import { GRIST_FORM } from '@/utils/constants'
|
||||||
|
|
||||||
// @todo try refactoring it to use MenuList component
|
// @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 { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
const { toggleEffects } = useSidePanel()
|
const { toggleEffects } = useSidePanel()
|
||||||
|
const { setDialogOpen } = useSettingsDialog()
|
||||||
return (
|
return (
|
||||||
<RACMenu
|
<RACMenu
|
||||||
style={{
|
style={{
|
||||||
@@ -35,12 +31,11 @@ export const OptionsMenuItems = ({
|
|||||||
<RiAccountBoxLine size={20} />
|
<RiAccountBoxLine size={20} />
|
||||||
{t('effects')}
|
{t('effects')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<TranscriptMenuItem />
|
|
||||||
</Section>
|
</Section>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Section>
|
<Section>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
href={GRIST_FORM}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className={menuRecipe({ icon: true }).item}
|
className={menuRecipe({ icon: true }).item}
|
||||||
>
|
>
|
||||||
@@ -49,7 +44,7 @@ export const OptionsMenuItems = ({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={menuRecipe({ icon: true }).item}
|
className={menuRecipe({ icon: true }).item}
|
||||||
onAction={() => onOpenDialog('settings')}
|
onAction={() => setDialogOpen(true)}
|
||||||
>
|
>
|
||||||
<RiSettings3Line size={20} />
|
<RiSettings3Line size={20} />
|
||||||
{t('settings')}
|
{t('settings')}
|
||||||
|
|||||||
-71
@@ -1,71 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+6
-3
@@ -84,9 +84,12 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
<RiMicOffFill color={'gray'} />
|
<RiMicOffFill color={'gray'} />
|
||||||
) : (
|
) : (
|
||||||
<RiMicFill
|
<RiMicFill
|
||||||
style={{
|
className={css({
|
||||||
animation: isSpeaking ? 'pulse_mic 800ms infinite' : undefined,
|
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
|
||||||
}}
|
animation: isSpeaking
|
||||||
|
? 'pulse_background 800ms infinite'
|
||||||
|
: undefined,
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+10
-2
@@ -4,8 +4,12 @@ import { ToggleButton } from '@/primitives'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useParticipants } from '@livekit/components-react'
|
import { useParticipants } from '@livekit/components-react'
|
||||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||||
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
|
||||||
export const ParticipantsToggle = () => {
|
export const ParticipantsToggle = ({
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}: ToggleButtonProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.participants' })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,8 +37,12 @@ export const ParticipantsToggle = () => {
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isParticipantsOpen}
|
isSelected={isParticipantsOpen}
|
||||||
onPress={() => toggleParticipants()}
|
onPress={(e) => {
|
||||||
|
toggleParticipants()
|
||||||
|
onPress?.(e)
|
||||||
|
}}
|
||||||
data-attr={`controls-participants-${tooltipLabel}`}
|
data-attr={`controls-participants-${tooltipLabel}`}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<RiGroupLine />
|
<RiGroupLine />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||||
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
|
||||||
export const ScreenShareToggle = (
|
type Props = Omit<
|
||||||
props: Omit<
|
UseTrackToggleProps<Track.Source.ScreenShare>,
|
||||||
UseTrackToggleProps<Track.Source.ScreenShare>,
|
'source' | 'captureOptions'
|
||||||
'source' | 'captureOptions'
|
> &
|
||||||
>
|
Pick<NonNullable<ButtonRecipeProps>, 'variant'> &
|
||||||
) => {
|
ToggleButtonProps
|
||||||
|
|
||||||
|
export const ScreenShareToggle = ({
|
||||||
|
variant = 'primaryDark',
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.screenShare' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.screenShare' })
|
||||||
const { buttonProps, enabled } = useTrackToggle({
|
const { buttonProps, enabled } = useTrackToggle({
|
||||||
...props,
|
...props,
|
||||||
@@ -26,18 +34,16 @@ export const ScreenShareToggle = (
|
|||||||
<ToggleButton
|
<ToggleButton
|
||||||
isSelected={enabled}
|
isSelected={enabled}
|
||||||
square
|
square
|
||||||
variant="primaryDark"
|
variant={variant}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
onPress={(e) =>
|
onPress={(e) => {
|
||||||
buttonProps.onClick?.(
|
buttonProps.onClick?.(
|
||||||
e as unknown as React.MouseEvent<HTMLButtonElement, MouseEvent>
|
e as unknown as React.MouseEvent<HTMLButtonElement, MouseEvent>
|
||||||
)
|
)
|
||||||
}
|
onPress?.(e)
|
||||||
style={{
|
|
||||||
maxWidth: '46px',
|
|
||||||
maxHeight: '46px',
|
|
||||||
}}
|
}}
|
||||||
data-attr={`controls-screenshare-${tooltipLabel}`}
|
data-attr={`controls-screenshare-${tooltipLabel}`}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<Div position="relative">
|
<Div position="relative">
|
||||||
<RiRectangleLine size={28} />
|
<RiRectangleLine size={28} />
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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,8 +3,9 @@ import { RiQuestionLine } from '@remixicon/react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Crisp } from 'crisp-sdk-web'
|
import { Crisp } from 'crisp-sdk-web'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
|
||||||
export const SupportToggle = () => {
|
export const SupportToggle = ({ onPress, ...props }: ToggleButtonProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls' })
|
||||||
const [isOpened, setIsOpened] = useState($crisp.is('chat:opened'))
|
const [isOpened, setIsOpened] = useState($crisp.is('chat:opened'))
|
||||||
|
|
||||||
@@ -32,8 +33,16 @@ export const SupportToggle = () => {
|
|||||||
tooltip={t('support')}
|
tooltip={t('support')}
|
||||||
aria-label={t('support')}
|
aria-label={t('support')}
|
||||||
isSelected={isOpened}
|
isSelected={isOpened}
|
||||||
onPress={() => (isOpened ? Crisp.chat.close() : Crisp.chat.open())}
|
onPress={(e) => {
|
||||||
|
if (isOpened) {
|
||||||
|
Crisp.chat.close()
|
||||||
|
} else {
|
||||||
|
Crisp.chat.open()
|
||||||
|
}
|
||||||
|
onPress?.(e)
|
||||||
|
}}
|
||||||
data-attr="controls-support"
|
data-attr="controls-support"
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<RiQuestionLine />
|
<RiQuestionLine />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { useRoomData } from './useRoomData'
|
||||||
|
|
||||||
|
export const useIsAdminOrOwner = () => {
|
||||||
|
const apiRoomData = useRoomData()
|
||||||
|
return apiRoomData?.is_administrable
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { useRoomData } from './useRoomData'
|
||||||
|
|
||||||
|
export const useRoomId = () => {
|
||||||
|
const apiRoomData = useRoomData()
|
||||||
|
return apiRoomData?.livekit?.room
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export enum PanelId {
|
|||||||
PARTICIPANTS = 'participants',
|
PARTICIPANTS = 'participants',
|
||||||
EFFECTS = 'effects',
|
EFFECTS = 'effects',
|
||||||
CHAT = 'chat',
|
CHAT = 'chat',
|
||||||
|
TRANSCRIPT = 'transcript',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSidePanel = () => {
|
export const useSidePanel = () => {
|
||||||
@@ -14,6 +15,7 @@ export const useSidePanel = () => {
|
|||||||
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
|
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
|
||||||
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
||||||
const isChatOpen = activePanelId == PanelId.CHAT
|
const isChatOpen = activePanelId == PanelId.CHAT
|
||||||
|
const isTranscriptOpen = activePanelId == PanelId.TRANSCRIPT
|
||||||
const isSidePanelOpen = !!activePanelId
|
const isSidePanelOpen = !!activePanelId
|
||||||
|
|
||||||
const toggleParticipants = () => {
|
const toggleParticipants = () => {
|
||||||
@@ -28,14 +30,20 @@ export const useSidePanel = () => {
|
|||||||
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleTranscript = () => {
|
||||||
|
layoutStore.activePanelId = isTranscriptOpen ? null : PanelId.TRANSCRIPT
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activePanelId,
|
activePanelId,
|
||||||
toggleParticipants,
|
toggleParticipants,
|
||||||
toggleChat,
|
toggleChat,
|
||||||
toggleEffects,
|
toggleEffects,
|
||||||
|
toggleTranscript,
|
||||||
isChatOpen,
|
isChatOpen,
|
||||||
isParticipantsOpen,
|
isParticipantsOpen,
|
||||||
isEffectsOpen,
|
isEffectsOpen,
|
||||||
isSidePanelOpen,
|
isSidePanelOpen,
|
||||||
|
isTranscriptOpen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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,
|
useCreateLayoutContext,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
|
|
||||||
import { ControlBar } from './ControlBar'
|
import { ControlBar } from './ControlBar/ControlBar'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { cva } from '@/styled-system/css'
|
import { cva } from '@/styled-system/css'
|
||||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||||
@@ -28,6 +28,7 @@ import { FocusLayout } from '../components/FocusLayout'
|
|||||||
import { ParticipantTile } from '../components/ParticipantTile'
|
import { ParticipantTile } from '../components/ParticipantTile'
|
||||||
import { SidePanel } from '../components/SidePanel'
|
import { SidePanel } from '../components/SidePanel'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
|
import { RecordingStateToast } from '../components/RecordingStateToast'
|
||||||
|
|
||||||
const LayoutWrapper = styled(
|
const LayoutWrapper = styled(
|
||||||
'div',
|
'div',
|
||||||
@@ -212,6 +213,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
)}
|
)}
|
||||||
<RoomAudioRenderer />
|
<RoomAudioRenderer />
|
||||||
<ConnectionStateToast />
|
<ConnectionStateToast />
|
||||||
|
<RecordingStateToast />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { Heading } from 'react-aria-components'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
RiAccountCircleLine,
|
RiAccountCircleLine,
|
||||||
|
RiNotification3Line,
|
||||||
RiSettings3Line,
|
RiSettings3Line,
|
||||||
RiSpeakerLine,
|
RiSpeakerLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { AccountTab } from './tabs/AccountTab'
|
import { AccountTab } from './tabs/AccountTab'
|
||||||
import { GeneralTab } from '@/features/settings/components/tabs/GeneralTab.tsx'
|
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||||
import { AudioTab } from '@/features/settings/components/tabs/AudioTab.tsx'
|
import { GeneralTab } from './tabs/GeneralTab'
|
||||||
|
import { AudioTab } from './tabs/AudioTab'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
|
||||||
@@ -81,12 +83,17 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
<RiSettings3Line />
|
<RiSettings3Line />
|
||||||
{isWideScreen && t('tabs.general')}
|
{isWideScreen && t('tabs.general')}
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab icon highlight id="4">
|
||||||
|
<RiNotification3Line />
|
||||||
|
{isWideScreen && t('tabs.notifications')}
|
||||||
|
</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
</div>
|
</div>
|
||||||
<div className={tabPanelContainerStyle}>
|
<div className={tabPanelContainerStyle}>
|
||||||
<AccountTab id="1" onOpenChange={props.onOpenChange} />
|
<AccountTab id="1" onOpenChange={props.onOpenChange} />
|
||||||
<AudioTab id="2" />
|
<AudioTab id="2" />
|
||||||
<GeneralTab id="3" />
|
<GeneralTab id="3" />
|
||||||
|
<NotificationsTab id="4" />
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -71,6 +71,10 @@
|
|||||||
"open": "",
|
"open": "",
|
||||||
"closed": ""
|
"closed": ""
|
||||||
},
|
},
|
||||||
|
"transcript": {
|
||||||
|
"open": "",
|
||||||
|
"closed": ""
|
||||||
|
},
|
||||||
"support": ""
|
"support": ""
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -79,11 +83,7 @@
|
|||||||
"feedbacks": "",
|
"feedbacks": "",
|
||||||
"settings": "",
|
"settings": "",
|
||||||
"username": "",
|
"username": "",
|
||||||
"effects": "",
|
"effects": ""
|
||||||
"transcript": {
|
|
||||||
"start": "",
|
|
||||||
"stop": ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"effects": {
|
"effects": {
|
||||||
@@ -102,18 +102,32 @@
|
|||||||
"heading": {
|
"heading": {
|
||||||
"participants": "",
|
"participants": "",
|
||||||
"effects": "",
|
"effects": "",
|
||||||
"chat": ""
|
"chat": "",
|
||||||
|
"transcript": ""
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"participants": "",
|
"participants": "",
|
||||||
"effects": "",
|
"effects": "",
|
||||||
"chat": ""
|
"chat": "",
|
||||||
|
"transcript": ""
|
||||||
},
|
},
|
||||||
"closeButton": ""
|
"closeButton": ""
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"disclaimer": ""
|
"disclaimer": ""
|
||||||
},
|
},
|
||||||
|
"transcript": {
|
||||||
|
"start": {
|
||||||
|
"heading": "",
|
||||||
|
"body": "",
|
||||||
|
"button": ""
|
||||||
|
},
|
||||||
|
"stop": {
|
||||||
|
"heading": "",
|
||||||
|
"body": "",
|
||||||
|
"button": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"rating": {
|
"rating": {
|
||||||
"submit": "",
|
"submit": "",
|
||||||
"question": "",
|
"question": "",
|
||||||
@@ -150,5 +164,8 @@
|
|||||||
"raisedHands": "",
|
"raisedHands": "",
|
||||||
"lowerParticipantHand": "",
|
"lowerParticipantHand": "",
|
||||||
"lowerParticipantsHand": ""
|
"lowerParticipantsHand": ""
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"label": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,19 @@
|
|||||||
},
|
},
|
||||||
"permissionsRequired": ""
|
"permissionsRequired": ""
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"heading": "",
|
||||||
|
"label": "",
|
||||||
|
"actions": {
|
||||||
|
"disable": "",
|
||||||
|
"enable": ""
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"participantJoined": "",
|
||||||
|
"handRaised": "",
|
||||||
|
"messageReceived": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"heading": ""
|
"heading": ""
|
||||||
},
|
},
|
||||||
@@ -30,6 +43,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"account": "",
|
"account": "",
|
||||||
"audio": "",
|
"audio": "",
|
||||||
"general": ""
|
"general": "",
|
||||||
|
"notifications": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,10 @@
|
|||||||
"open": "Hide everyone",
|
"open": "Hide everyone",
|
||||||
"closed": "See everyone"
|
"closed": "See everyone"
|
||||||
},
|
},
|
||||||
|
"transcript": {
|
||||||
|
"open": "Hide AI assistant",
|
||||||
|
"closed": "Show AI assistant"
|
||||||
|
},
|
||||||
"support": "Support"
|
"support": "Support"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -78,11 +82,7 @@
|
|||||||
"feedbacks": "Give us feedbacks",
|
"feedbacks": "Give us feedbacks",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"username": "Update Your Name",
|
"username": "Update Your Name",
|
||||||
"effects": "Apply effects",
|
"effects": "Apply effects"
|
||||||
"transcript": {
|
|
||||||
"start": "Start meeting transcription",
|
|
||||||
"stop": "Stop ongoing transcription"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"effects": {
|
"effects": {
|
||||||
@@ -101,18 +101,32 @@
|
|||||||
"heading": {
|
"heading": {
|
||||||
"participants": "Participants",
|
"participants": "Participants",
|
||||||
"effects": "Effects",
|
"effects": "Effects",
|
||||||
"chat": "Messages in the chat"
|
"chat": "Messages in the chat",
|
||||||
|
"transcript": "AI Assistant"
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"participants": "participants",
|
"participants": "participants",
|
||||||
"effects": "effects",
|
"effects": "effects",
|
||||||
"chat": "messages"
|
"chat": "messages",
|
||||||
|
"transcript": "AI assistant"
|
||||||
},
|
},
|
||||||
"closeButton": "Hide {{content}}"
|
"closeButton": "Hide {{content}}"
|
||||||
},
|
},
|
||||||
"chat": {
|
"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."
|
"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": {
|
"rating": {
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"question": "What do you think about the quality of your call?",
|
"question": "What do you think about the quality of your call?",
|
||||||
@@ -149,5 +163,8 @@
|
|||||||
"raisedHands": "Raised hands",
|
"raisedHands": "Raised hands",
|
||||||
"lowerParticipantHand": "Lower {{name}}'s hand",
|
"lowerParticipantHand": "Lower {{name}}'s hand",
|
||||||
"lowerParticipantsHand": "Lower all hands"
|
"lowerParticipantsHand": "Lower all hands"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"label": "Recording"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,19 @@
|
|||||||
},
|
},
|
||||||
"permissionsRequired": "Permissions required"
|
"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": {
|
"dialog": {
|
||||||
"heading": "Settings"
|
"heading": "Settings"
|
||||||
},
|
},
|
||||||
@@ -30,6 +43,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"account": "Profile",
|
"account": "Profile",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"general": "General"
|
"general": "General",
|
||||||
|
"notifications": "Notifications"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,10 @@
|
|||||||
"open": "Masquer les participants",
|
"open": "Masquer les participants",
|
||||||
"closed": "Afficher les participants"
|
"closed": "Afficher les participants"
|
||||||
},
|
},
|
||||||
|
"transcript": {
|
||||||
|
"open": "Masquer l'assistant IA",
|
||||||
|
"closed": "Afficher l'assistant IA"
|
||||||
|
},
|
||||||
"support": "Support"
|
"support": "Support"
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@@ -78,11 +82,7 @@
|
|||||||
"feedbacks": "Partager votre avis",
|
"feedbacks": "Partager votre avis",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"username": "Choisir votre nom",
|
"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": {
|
"effects": {
|
||||||
@@ -101,18 +101,32 @@
|
|||||||
"heading": {
|
"heading": {
|
||||||
"participants": "Participants",
|
"participants": "Participants",
|
||||||
"effects": "Effets",
|
"effects": "Effets",
|
||||||
"chat": "Messages dans l'appel"
|
"chat": "Messages dans l'appel",
|
||||||
|
"transcript": "Assistant IA"
|
||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"participants": "les participants",
|
"participants": "les participants",
|
||||||
"effects": "les effets",
|
"effects": "les effets",
|
||||||
"chat": "les messages"
|
"chat": "les messages",
|
||||||
|
"transcript": "l'assistant IA"
|
||||||
},
|
},
|
||||||
"closeButton": "Masquer {{content}}"
|
"closeButton": "Masquer {{content}}"
|
||||||
},
|
},
|
||||||
"chat": {
|
"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."
|
"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": {
|
"rating": {
|
||||||
"submit": "Envoyer",
|
"submit": "Envoyer",
|
||||||
"question": "Que pensez-vous de la qualité de votre appel ?",
|
"question": "Que pensez-vous de la qualité de votre appel ?",
|
||||||
@@ -149,5 +163,8 @@
|
|||||||
"raisedHands": "Mains levées",
|
"raisedHands": "Mains levées",
|
||||||
"lowerParticipantHand": "Baisser la main de {{name}}",
|
"lowerParticipantHand": "Baisser la main de {{name}}",
|
||||||
"lowerParticipantsHand": "Baisser la main de tous les participants"
|
"lowerParticipantsHand": "Baisser la main de tous les participants"
|
||||||
|
},
|
||||||
|
"recording": {
|
||||||
|
"label": "Enregistrement"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,19 @@
|
|||||||
},
|
},
|
||||||
"permissionsRequired": "Autorisations nécessaires"
|
"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": {
|
"dialog": {
|
||||||
"heading": "Paramètres"
|
"heading": "Paramètres"
|
||||||
},
|
},
|
||||||
@@ -30,6 +43,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"account": "Profile",
|
"account": "Profile",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"general": "Général"
|
"general": "Général",
|
||||||
|
"notifications": "Notifications"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import {
|
|||||||
import { type RecipeVariantProps } from '@/styled-system/css'
|
import { type RecipeVariantProps } from '@/styled-system/css'
|
||||||
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
||||||
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
|
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
|
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||||
RACButtonsProps &
|
RACButtonsProps &
|
||||||
TooltipWrapperProps
|
TooltipWrapperProps & {
|
||||||
|
// Use tooltip as description below the button.
|
||||||
|
description?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const Button = ({
|
export const Button = ({
|
||||||
tooltip,
|
tooltip,
|
||||||
@@ -22,7 +26,10 @@ export const Button = ({
|
|||||||
<RACButton
|
<RACButton
|
||||||
className={buttonRecipe(variantProps)}
|
className={buttonRecipe(variantProps)}
|
||||||
{...(componentProps as RACButtonsProps)}
|
{...(componentProps as RACButtonsProps)}
|
||||||
/>
|
>
|
||||||
|
{componentProps.children as ReactNode}
|
||||||
|
{props.description && <span>{tooltip}</span>}
|
||||||
|
</RACButton>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { Link, LinkProps } from 'react-aria-components'
|
|||||||
import { type RecipeVariantProps } from '@/styled-system/css'
|
import { type RecipeVariantProps } from '@/styled-system/css'
|
||||||
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
|
||||||
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
|
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
|
type LinkButtonProps = RecipeVariantProps<ButtonRecipe> &
|
||||||
LinkProps &
|
LinkProps &
|
||||||
TooltipWrapperProps
|
TooltipWrapperProps & {
|
||||||
|
// Use tooltip as description below the button.
|
||||||
|
description?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const LinkButton = ({
|
export const LinkButton = ({
|
||||||
tooltip,
|
tooltip,
|
||||||
@@ -16,7 +20,12 @@ export const LinkButton = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||||
<Link className={buttonRecipe(variantProps)} {...componentProps} />
|
<Link className={buttonRecipe(variantProps)} {...componentProps}>
|
||||||
|
<>
|
||||||
|
{componentProps.children as ReactNode}
|
||||||
|
{props.description && <span>{tooltip}</span>}
|
||||||
|
</>
|
||||||
|
</Link>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
@@ -55,6 +55,14 @@ export const text = cva({
|
|||||||
textAlign: 'inherit',
|
textAlign: 'inherit',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
wrap: {
|
||||||
|
balance: {
|
||||||
|
textWrap: 'balance',
|
||||||
|
},
|
||||||
|
pretty: {
|
||||||
|
textWrap: 'pretty',
|
||||||
|
},
|
||||||
|
},
|
||||||
bold: {
|
bold: {
|
||||||
true: {
|
true: {
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
ToggleButton as RACToggleButton,
|
ToggleButton as RACToggleButton,
|
||||||
ToggleButtonProps,
|
ToggleButtonProps as RACToggleButtonProps,
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components'
|
||||||
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
|
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
|
||||||
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
|
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
|
* React aria ToggleButton with our button styles, that can take a tooltip if needed
|
||||||
@@ -12,14 +20,20 @@ export const ToggleButton = ({
|
|||||||
tooltip,
|
tooltip,
|
||||||
tooltipType,
|
tooltipType,
|
||||||
...props
|
...props
|
||||||
}: ToggleButtonProps & ButtonRecipeProps & TooltipWrapperProps) => {
|
}: ToggleButtonProps) => {
|
||||||
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
|
||||||
<RACToggleButton
|
<RACToggleButton
|
||||||
{...componentProps}
|
{...componentProps}
|
||||||
className={buttonRecipe(variantProps)}
|
className={buttonRecipe(variantProps)}
|
||||||
/>
|
>
|
||||||
|
<>
|
||||||
|
{componentProps.children as ReactNode}
|
||||||
|
{props.description && <span>{tooltip}</span>}
|
||||||
|
</>
|
||||||
|
</RACToggleButton>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { type RecipeVariantProps, cva } from '@/styled-system/css'
|
import { type RecipeVariantProps, cva } from '@/styled-system/css'
|
||||||
|
|
||||||
export type ButtonRecipe = typeof buttonRecipe
|
|
||||||
|
|
||||||
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
|
|
||||||
|
|
||||||
export const buttonRecipe = cva({
|
export const buttonRecipe = cva({
|
||||||
base: {
|
base: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -222,6 +218,16 @@ export const buttonRecipe = cva({
|
|||||||
shySelected: {
|
shySelected: {
|
||||||
true: {},
|
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
|
// if the button is next to other ones to make a "button group", tell where the button is to handle radius
|
||||||
groupPosition: {
|
groupPosition: {
|
||||||
left: {
|
left: {
|
||||||
@@ -255,3 +261,7 @@ export const buttonRecipe = cva({
|
|||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export type ButtonRecipe = typeof buttonRecipe
|
||||||
|
|
||||||
|
export type ButtonRecipeProps = RecipeVariantProps<ButtonRecipe>
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ export { ToggleButton } from './ToggleButton'
|
|||||||
export { Ul } from './Ul'
|
export { Ul } from './Ul'
|
||||||
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
||||||
export { TextArea } from './TextArea'
|
export { TextArea } from './TextArea'
|
||||||
|
export { Switch } from './Switch'
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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)
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -29,3 +29,12 @@ body,
|
|||||||
body:has(.lk-video-conference) #crisp-chatbox > div > a {
|
body:has(.lk-video-conference) #crisp-chatbox > div > a {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-full {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const GRIST_FORM =
|
||||||
|
'https://grist.numerique.gouv.fr/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4' as const
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Object containing all localStorage keys used across the app
|
||||||
|
*/
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
NOTIFICATIONS: 'app_notification_settings',
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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
@@ -0,0 +1,43 @@
|
|||||||
|
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: {}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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: {}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
|
||||||
|
livekit:
|
||||||
|
keys:
|
||||||
|
devkey: secret
|
||||||
|
livekitApi:
|
||||||
|
key: devkey
|
||||||
|
secret: secret
|
||||||
|
oidc:
|
||||||
|
clientId: meet
|
||||||
|
clientSecret: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
@@ -17,6 +17,10 @@ livekit:
|
|||||||
udp_port: 443
|
udp_port: 443
|
||||||
domain: livekit.127.0.0.1.nip.io
|
domain: livekit.127.0.0.1.nip.io
|
||||||
loadBalancerAnnotations: {}
|
loadBalancerAnnotations: {}
|
||||||
|
webhook:
|
||||||
|
api_key: devkey
|
||||||
|
urls:
|
||||||
|
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/livekit-webhook/
|
||||||
|
|
||||||
|
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ backend:
|
|||||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
RECORDING_STORAGE_EVENT_TOKEN: password
|
||||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
||||||
SUMMARY_SERVICE_API_TOKEN: password
|
SUMMARY_SERVICE_API_TOKEN: password
|
||||||
|
PASSPHRASE_ENCRYPTION_KEY: lT3cX5dzFhCe-9xNjXUiTCX00r2ZgHgGUJKO66x-QIo=
|
||||||
|
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
@@ -125,6 +126,9 @@ summary:
|
|||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
OPENAI_API_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
|
AWS_S3_SECURE_ACCESS: False
|
||||||
WEBHOOK_API_TOKEN: password
|
WEBHOOK_API_TOKEN: password
|
||||||
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
||||||
@@ -155,6 +159,9 @@ celery:
|
|||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
OPENAI_API_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
|
AWS_S3_SECURE_ACCESS: False
|
||||||
WEBHOOK_API_TOKEN: password
|
WEBHOOK_API_TOKEN: password
|
||||||
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-backend
|
repository: lasuite/meet-backend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "v0.1.10"
|
tag: "v0.1.12"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
migrateJobAnnotations:
|
migrateJobAnnotations:
|
||||||
@@ -116,6 +116,17 @@ backend:
|
|||||||
name: meet-media-storage.bucket.libre.sh
|
name: meet-media-storage.bucket.libre.sh
|
||||||
key: bucket
|
key: bucket
|
||||||
AWS_S3_REGION_NAME: local
|
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:
|
createsuperuser:
|
||||||
command:
|
command:
|
||||||
@@ -129,7 +140,7 @@ frontend:
|
|||||||
image:
|
image:
|
||||||
repository: lasuite/meet-frontend
|
repository: lasuite/meet-frontend
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
tag: "v0.1.10"
|
tag: "v0.1.12"
|
||||||
|
|
||||||
ingress:
|
ingress:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -167,7 +178,113 @@ posthog:
|
|||||||
nginx.ingress.kubernetes.io/backend-protocol: https
|
nginx.ingress.kubernetes.io/backend-protocol: https
|
||||||
|
|
||||||
summary:
|
summary:
|
||||||
replicas: 0
|
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"
|
||||||
|
|
||||||
celery:
|
celery:
|
||||||
replicas: 0
|
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"
|
||||||
|
|||||||
@@ -216,11 +216,14 @@ summary:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: summary
|
name: summary
|
||||||
key: OPENAI_API_KEY
|
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:
|
WEBHOOK_API_TOKEN:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: summary
|
name: summary
|
||||||
key: WEBHOOK_API_TOKEN
|
key: WEBHOOK_API_TOKEN
|
||||||
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
WEBHOOK_URL: https://impress-staging.beta.numerique.gouv.fr/api/v1.0/documents/create-for-owner/
|
||||||
CELERY_BROKER_URL:
|
CELERY_BROKER_URL:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: redis-summary.redis.libre.sh
|
name: redis-summary.redis.libre.sh
|
||||||
@@ -264,11 +267,14 @@ celery:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: summary
|
name: summary
|
||||||
key: OPENAI_API_KEY
|
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:
|
WEBHOOK_API_TOKEN:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: summary
|
name: summary
|
||||||
key: WEBHOOK_API_TOKEN
|
key: WEBHOOK_API_TOKEN
|
||||||
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
WEBHOOK_URL: https://impress-staging.beta.numerique.gouv.fr/api/v1.0/documents/create-for-owner/
|
||||||
CELERY_BROKER_URL:
|
CELERY_BROKER_URL:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: redis-summary.redis.libre.sh
|
name: redis-summary.redis.libre.sh
|
||||||
|
|||||||
+61
-7
@@ -1,4 +1,8 @@
|
|||||||
environments:
|
environments:
|
||||||
|
dev-keycloak:
|
||||||
|
values:
|
||||||
|
- version: 0.0.1
|
||||||
|
- env.d/{{ .Environment.Name }}/values.secrets.yaml
|
||||||
dev:
|
dev:
|
||||||
values:
|
values:
|
||||||
- version: 0.0.1
|
- version: 0.0.1
|
||||||
@@ -32,7 +36,8 @@ repositories:
|
|||||||
|
|
||||||
releases:
|
releases:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
installed: {{ eq .Environment.Name "dev" | toYaml }}
|
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||||
|
missingFileHandler: Warn
|
||||||
namespace: {{ .Namespace }}
|
namespace: {{ .Namespace }}
|
||||||
chart: bitnami/postgresql
|
chart: bitnami/postgresql
|
||||||
version: 13.1.5
|
version: 13.1.5
|
||||||
@@ -45,9 +50,50 @@ releases:
|
|||||||
enabled: true
|
enabled: true
|
||||||
autoGenerated: true
|
autoGenerated: true
|
||||||
|
|
||||||
- name: minio
|
- name: keycloak
|
||||||
installed: {{ eq .Environment.Name "dev" | toYaml }}
|
installed: {{ eq .Environment.Name "dev-keycloak" | toYaml }}
|
||||||
|
missingFileHandler: Warn
|
||||||
namespace: {{ .Namespace }}
|
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 }}
|
||||||
|
namespace: {{ .Namespace }}
|
||||||
|
missingFileHandler: Warn
|
||||||
chart: bitnami/minio
|
chart: bitnami/minio
|
||||||
version: 12.10.10
|
version: 12.10.10
|
||||||
values:
|
values:
|
||||||
@@ -75,7 +121,8 @@ releases:
|
|||||||
name: mkcert
|
name: mkcert
|
||||||
|
|
||||||
- name: redis
|
- name: redis
|
||||||
installed: {{ eq .Environment.Name "dev" | toYaml }}
|
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||||
|
missingFileHandler: Warn
|
||||||
namespace: {{ .Namespace }}
|
namespace: {{ .Namespace }}
|
||||||
chart: bitnami/redis
|
chart: bitnami/redis
|
||||||
version: 18.19.2
|
version: 18.19.2
|
||||||
@@ -85,7 +132,8 @@ releases:
|
|||||||
architecture: standalone
|
architecture: standalone
|
||||||
|
|
||||||
- name: extra
|
- name: extra
|
||||||
installed: {{ ne .Environment.Name "dev" | toYaml }}
|
installed: {{ not (regexMatch "^dev.*" .Environment.Name) | toYaml }}
|
||||||
|
missingFileHandler: Warn
|
||||||
namespace: {{ .Namespace }}
|
namespace: {{ .Namespace }}
|
||||||
chart: ./extra
|
chart: ./extra
|
||||||
secrets:
|
secrets:
|
||||||
@@ -100,26 +148,32 @@ releases:
|
|||||||
- name: meet
|
- name: meet
|
||||||
version: {{ .Values.version }}
|
version: {{ .Values.version }}
|
||||||
namespace: {{ .Namespace }}
|
namespace: {{ .Namespace }}
|
||||||
|
missingFileHandler: Warn
|
||||||
chart: ./meet
|
chart: ./meet
|
||||||
values:
|
values:
|
||||||
- env.d/{{ .Environment.Name }}/values.meet.yaml.gotmpl
|
- env.d/{{ .Environment.Name }}/values.meet.yaml.gotmpl
|
||||||
|
- env.d/{{ .Environment.Name }}/values.secrets.yaml
|
||||||
secrets:
|
secrets:
|
||||||
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
|
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
|
||||||
|
|
||||||
- name: livekit
|
- name: livekit
|
||||||
installed: {{ eq .Environment.Name "dev" | toYaml }}
|
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||||
|
missingFileHandler: Warn
|
||||||
namespace: {{ .Namespace }}
|
namespace: {{ .Namespace }}
|
||||||
chart: livekit/livekit-server
|
chart: livekit/livekit-server
|
||||||
values:
|
values:
|
||||||
- env.d/{{ .Environment.Name }}/values.livekit.yaml.gotmpl
|
- env.d/{{ .Environment.Name }}/values.livekit.yaml.gotmpl
|
||||||
|
- env.d/{{ .Environment.Name }}/values.secrets.yaml
|
||||||
secrets:
|
secrets:
|
||||||
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
|
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
|
||||||
|
|
||||||
- name: livekit-egress
|
- name: livekit-egress
|
||||||
installed: {{ eq .Environment.Name "dev" | toYaml }}
|
installed: {{ regexMatch "^dev.*" .Environment.Name | toYaml }}
|
||||||
|
missingFileHandler: Warn
|
||||||
namespace: {{ .Namespace }}
|
namespace: {{ .Namespace }}
|
||||||
chart: livekit/egress
|
chart: livekit/egress
|
||||||
values:
|
values:
|
||||||
- env.d/{{ .Environment.Name }}/values.egress.yaml.gotmpl
|
- env.d/{{ .Environment.Name }}/values.egress.yaml.gotmpl
|
||||||
|
- env.d/{{ .Environment.Name }}/values.secrets.yaml
|
||||||
secrets:
|
secrets:
|
||||||
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
|
- env.d/{{ .Environment.Name }}/secrets.enc.yaml
|
||||||
|
|||||||
Generated
+5
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.5.4",
|
"@html-to/text-cli": "0.5.4",
|
||||||
@@ -344,8 +344,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.6",
|
||||||
"license": "MIT",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
"shebang-command": "^2.0.0",
|
"shebang-command": "^2.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "0.1.10",
|
"version": "0.1.12",
|
||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ dependencies = [
|
|||||||
"pydantic>=2.5.0",
|
"pydantic>=2.5.0",
|
||||||
"pydantic-settings>=2.1.0",
|
"pydantic-settings>=2.1.0",
|
||||||
"celery==5.4.0",
|
"celery==5.4.0",
|
||||||
"redis==4.5.4",
|
"redis==5.2.1",
|
||||||
"minio==7.2.9",
|
"minio==7.2.13",
|
||||||
"openai==1.55.3",
|
"openai==1.58.1",
|
||||||
"requests==2.32.3",
|
"requests==2.32.3",
|
||||||
"sentry-sdk[fastapi, celery]==2.19.0",
|
"sentry-sdk[fastapi, celery]==2.19.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.7.4",
|
"ruff==0.8.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Celery workers."""
|
"""Celery workers."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ celery = Celery(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if settings.sentry_dsn and settings.sentry_is_enabled:
|
if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||||
|
|
||||||
@signals.celeryd_init.connect
|
@signals.celeryd_init.connect
|
||||||
def init_sentry(**_kwargs):
|
def init_sentry(**_kwargs):
|
||||||
"""Initialize sentry."""
|
"""Initialize sentry."""
|
||||||
@@ -100,15 +102,20 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
|
|||||||
api_key=settings.openai_api_key, base_url=settings.openai_base_url
|
api_key=settings.openai_api_key, base_url=settings.openai_base_url
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Querying transcription …")
|
try:
|
||||||
with open(temp_file_path, "rb") as audio_file:
|
logger.debug("Querying transcription …")
|
||||||
transcription = openai_client.audio.transcriptions.create(
|
with open(temp_file_path, "rb") as audio_file:
|
||||||
model=settings.openai_asr_model, file=audio_file
|
transcription = openai_client.audio.transcriptions.create(
|
||||||
)
|
model=settings.openai_asr_model, file=audio_file
|
||||||
|
)
|
||||||
|
|
||||||
transcription = transcription.text
|
transcription = transcription.text
|
||||||
|
|
||||||
logger.debug("Transcription: \n %s", transcription)
|
logger.debug("Transcription: \n %s", transcription)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_file_path):
|
||||||
|
os.remove(temp_file_path)
|
||||||
|
logger.debug("Temporary file removed: %s", temp_file_path)
|
||||||
|
|
||||||
instructions = get_instructions(transcription)
|
instructions = get_instructions(transcription)
|
||||||
summary_response = openai_client.chat.completions.create(
|
summary_response = openai_client.chat.completions.create(
|
||||||
@@ -118,8 +125,10 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
|
|||||||
summary = summary_response.choices[0].message.content
|
summary = summary_response.choices[0].message.content
|
||||||
logger.debug("Summary: \n %s", summary)
|
logger.debug("Summary: \n %s", summary)
|
||||||
|
|
||||||
|
# fixme - generate a title using LLM
|
||||||
data = {
|
data = {
|
||||||
"summary": summary,
|
"title": "Votre résumé",
|
||||||
|
"content": summary,
|
||||||
"email": email,
|
"email": email,
|
||||||
"sub": sub,
|
"sub": sub,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user