Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24509c96d1 | |||
| 88eddd85d7 | |||
| f3eeeff96f | |||
| 1e339023b3 | |||
| 30bde2fe66 | |||
| 96c18fc627 | |||
| c380ff5e1d | |||
| 6587863afb | |||
| a06daad33d | |||
| 10c3c67d53 | |||
| fa85ef5690 | |||
| cc676cf71b | |||
| 90df8fe5b4 | |||
| 90cfcd31c8 | |||
| 0ed3416ce2 | |||
| e47401246f | |||
| a518179efb | |||
| f17471dae5 | |||
| 36ea44befc | |||
| 047ef83f17 | |||
| e1ece8b5af | |||
| 56379f2d6e | |||
| 341be37fd3 | |||
| 4cce2e0b72 | |||
| f14536dd93 | |||
| dc0e2eefb3 | |||
| a61b34400b | |||
| 3c8e3b9e29 | |||
| 89f2ae548e | |||
| 1f23bbf96e | |||
| 9972692dac | |||
| 6d08e318a7 | |||
| 203f1762e7 | |||
| 470390fc59 | |||
| fe9fe4dd90 | |||
| 7ad9015a6b | |||
| 3e4a7058d2 | |||
| 25a4e2dfc6 | |||
| 14e83ecaff | |||
| 8bd90bd2ff | |||
| b51f127872 | |||
| 4c0230d537 | |||
| 7309df4115 | |||
| 18b2dfc497 | |||
| 3282da7c56 | |||
| 7f8a6e8685 |
@@ -2,30 +2,14 @@ name: Release Chart
|
||||
run-name: Release Chart
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
push:
|
||||
paths:
|
||||
- ./src/helm/meet/**
|
||||
|
||||
jobs:
|
||||
lint-helmfile:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
- uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "meet,secrets"
|
||||
|
||||
release:
|
||||
needs: helmfile-lint
|
||||
if: github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -47,5 +31,5 @@ jobs:
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
linting: off
|
||||
linting: on
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -301,34 +301,8 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||
./bin/start-kind.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
install-secret: ## install the kubernetes secrets from Vaultwarden
|
||||
if kubectl -n meet get secrets bitwarden-cli-visio; then \
|
||||
echo "Secret already present"; \
|
||||
else \
|
||||
echo "Please provide the following information:"; \
|
||||
read -p "Enter your vaultwarden email login: " LOGIN; \
|
||||
read -p "Enter your vaultwarden password: " PASSWORD; \
|
||||
read -p "Enter your vaultwarden server url: " URL; \
|
||||
echo "\nCreate vaultwarden secret"; \
|
||||
echo "apiVersion: v1" > /tmp/secret.yaml; \
|
||||
echo "kind: Secret" >> /tmp/secret.yaml; \
|
||||
echo "metadata:" >> /tmp/secret.yaml; \
|
||||
echo " name: bitwarden-cli-visio" >> /tmp/secret.yaml; \
|
||||
echo " namespace: meet" >> /tmp/secret.yaml; \
|
||||
echo "type: Opaque" >> /tmp/secret.yaml; \
|
||||
echo "stringData:" >> /tmp/secret.yaml; \
|
||||
echo " BW_HOST: $$URL" >> /tmp/secret.yaml; \
|
||||
echo " BW_PASSWORD: $$PASSWORD" >> /tmp/secret.yaml; \
|
||||
echo " BW_USERNAME: $$LOGIN" >> /tmp/secret.yaml; \
|
||||
kubectl -n meet apply -f /tmp/secret.yaml;\
|
||||
rm -f /tmp/secret.yaml; \
|
||||
helm repo add external-secrets https://charts.external-secrets.io; \
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n meet \
|
||||
--create-namespace \
|
||||
--set installCRDs=true; \
|
||||
fi
|
||||
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
||||
./bin/install-external-secrets.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
NAMESPACE=${1:-meet}
|
||||
SECRET_NAME=${2:-bitwarden-cli-meet}
|
||||
TEMP_SECRET_FILE=$(mktemp)
|
||||
|
||||
|
||||
cleanup() {
|
||||
rm -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
# Check if kubectl is available
|
||||
check_prerequisites() {
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
echo "Error: kubectl is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if secret already exists
|
||||
check_secret_exists() {
|
||||
kubectl -n "${NAMESPACE}" get secrets "${SECRET_NAME}" &> /dev/null
|
||||
}
|
||||
|
||||
|
||||
# Collect user input securely
|
||||
get_user_input() {
|
||||
echo "Please provide the following information:"
|
||||
read -p "Enter your Vaultwarden email login: " LOGIN
|
||||
read -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||
echo
|
||||
read -p "Enter your Vaultwarden server url: " URL
|
||||
}
|
||||
|
||||
# Create and apply the secret
|
||||
create_secret() {
|
||||
cat > "${TEMP_SECRET_FILE}" << EOF
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${SECRET_NAME}
|
||||
namespace: ${NAMESPACE}
|
||||
type: Opaque
|
||||
stringData:
|
||||
BW_HOST: ${URL}
|
||||
BW_PASSWORD: ${PASSWORD}
|
||||
BW_USERNAME: ${LOGIN}
|
||||
EOF
|
||||
|
||||
kubectl -n "${NAMESPACE}" apply -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
|
||||
# Install external-secrets using Helm
|
||||
install_external_secrets() {
|
||||
if ! kubectl get ns external-secrets &>/dev/null; then
|
||||
echo "Installing external-secrets…"
|
||||
helm repo add external-secrets https://charts.external-secrets.io
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n external-secrets \
|
||||
--create-namespace \
|
||||
--set installCRDs=true
|
||||
else
|
||||
echo "External secrets already deployed"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
check_prerequisites
|
||||
|
||||
if check_secret_exists; then
|
||||
echo "Secret '${SECRET_NAME}' already present in namespace '${NAMESPACE}'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e ${TEMP_SECRET_FILE}
|
||||
|
||||
get_user_input
|
||||
echo -e "\nCreating Vaultwarden secret…"
|
||||
create_secret
|
||||
install_external_secrets
|
||||
|
||||
echo "Secret installation completed successfully"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- meet
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
git submodule update --init --recursive
|
||||
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"eslint"
|
||||
"eslint", "react", "react-dom", "@types/react-dom", "@types/react", "react-i18next"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
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 _
|
||||
|
||||
import requests
|
||||
@@ -10,6 +10,11 @@ from mozilla_django_oidc.auth import (
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
from core.services.marketing_service import (
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
@@ -86,6 +91,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
password="!", # noqa: S106
|
||||
**claims,
|
||||
)
|
||||
|
||||
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
|
||||
elif not user:
|
||||
return None
|
||||
|
||||
@@ -96,6 +105,26 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||
|
||||
Details:
|
||||
1. Uses a very short timeout (1s) to prevent blocking the auth process
|
||||
2. Silently fails if the marketing service is down/slow to prioritize user experience
|
||||
3. Trade-off: May miss some signups but ensures auth flow remains fast
|
||||
|
||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||
"""
|
||||
try:
|
||||
marketing_service = get_marketing_service()
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
marketing_service.create_contact(contact_data, timeout=1)
|
||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||
pass
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-13 12:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='resourceaccess',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'Resource access', 'verbose_name_plural': 'Resource accesses'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='user',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
|
||||
),
|
||||
]
|
||||
@@ -189,6 +189,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_user"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
@@ -304,6 +305,7 @@ class ResourceAccess(BaseModel):
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_resource_access"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Resource access")
|
||||
verbose_name_plural = _("Resource accesses")
|
||||
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."""
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import UserFactory
|
||||
from core.services import marketing_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -412,3 +415,139 @@ def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
|
||||
user.refresh_from_db()
|
||||
|
||||
assert user.email == "john.doe@example.com"
|
||||
|
||||
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_new_user_enabled(mock_signup, monkeypatch, settings):
|
||||
"""Test marketing signup for new user with settings enabled."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "test@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
|
||||
assert user.email == email
|
||||
mock_signup.assert_called_once_with(email)
|
||||
|
||||
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_new_user_disabled(mock_signup, monkeypatch, settings):
|
||||
"""Test no marketing signup for new user with settings disabled."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "test@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
|
||||
assert user.email == email
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_new_user_default_disabled(mock_signup, monkeypatch):
|
||||
"""Test no marketing signup for new user with settings by default disabled."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "test@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
|
||||
assert user.email == email
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"is_signup_enabled",
|
||||
[True, False],
|
||||
)
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_existing_user(
|
||||
mock_signup, monkeypatch, settings, is_signup_enabled
|
||||
):
|
||||
"""Test no marketing signup for existing user regardless of settings."""
|
||||
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = is_signup_enabled
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="test@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub, "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
assert user == db_user
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_signup_to_marketing_email_success(mock_marketing):
|
||||
"""Test successful marketing signup."""
|
||||
|
||||
email = "test@example.com"
|
||||
|
||||
# Call the method
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email(email)
|
||||
|
||||
# Verify service interaction
|
||||
mock_service = mock_marketing.return_value
|
||||
mock_service.create_contact.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
ImportError,
|
||||
ImproperlyConfigured,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_marketing_signup_handles_service_initialization_errors(
|
||||
mock_marketing, error, settings
|
||||
):
|
||||
"""Tests errors that occur when trying to get/initialize the marketing service."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
mock_marketing.side_effect = error
|
||||
|
||||
# Should not raise any exception
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
marketing_service.ContactCreationError,
|
||||
ImproperlyConfigured,
|
||||
ImportError,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_marketing_signup_handles_contact_creation_errors(
|
||||
mock_marketing, error, settings
|
||||
):
|
||||
"""Tests errors that occur during the contact creation process."""
|
||||
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
mock_marketing.return_value.create_contact.side_effect = error
|
||||
|
||||
# Should not raise any exception
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
||||
|
||||
@@ -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
|
||||
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():
|
||||
|
||||
@@ -457,6 +457,28 @@ class Base(Configuration):
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
|
||||
# Marketing and communication settings
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||
False, # When enabled, new users are automatically added to mailing list.
|
||||
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
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})
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
|
||||
+12
-11
@@ -25,8 +25,9 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.76",
|
||||
"boto3==1.36.2",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.6.0",
|
||||
@@ -36,7 +37,7 @@ dependencies = [
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.4",
|
||||
"django==5.1.5",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
@@ -47,17 +48,17 @@ dependencies = [
|
||||
"june-analytics-python==2.3.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"psycopg[binary]==3.2.4",
|
||||
"PyJWT==2.10.1",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.19.2",
|
||||
"sentry-sdk==2.20.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.8.2",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.8.0",
|
||||
"aiohttp==3.11.10",
|
||||
"livekit-api==0.8.1",
|
||||
"aiohttp==3.11.11",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -72,17 +73,17 @@ dev = [
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.30.0",
|
||||
"pyfakefs==5.7.2",
|
||||
"ipython==8.31.0",
|
||||
"pyfakefs==5.7.4",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.2",
|
||||
"pylint==3.3.3",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.4",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.8.2",
|
||||
"responses==0.25.6",
|
||||
"ruff==0.9.2",
|
||||
"types-requests==2.32.0.20241016",
|
||||
]
|
||||
|
||||
|
||||
Generated
+2139
-1760
File diff suppressed because it is too large
Load Diff
+28
-28
@@ -13,23 +13,23 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.6.9",
|
||||
"@livekit/components-react": "2.8.0",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.2",
|
||||
"@pandacss/preset-panda": "0.48.0",
|
||||
"@react-aria/toast": "3.0.0-beta.18",
|
||||
"@remixicon/react": "4.5.0",
|
||||
"@tanstack/react-query": "5.61.3",
|
||||
"@livekit/track-processors": "0.3.3",
|
||||
"@pandacss/preset-panda": "0.51.1",
|
||||
"@react-aria/toast": "3.0.0-beta.19",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.64.2",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.1",
|
||||
"i18next": "24.0.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-parser": "9.0.2",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "24.2.1",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"i18next-parser": "9.1.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.6.3",
|
||||
"posthog-js": "1.188.0",
|
||||
"livekit-client": "2.8.1",
|
||||
"posthog-js": "1.207.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.5.0",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "4.0.3",
|
||||
@@ -37,24 +37,24 @@
|
||||
"wouter": "3.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.48.0",
|
||||
"@tanstack/eslint-plugin-query": "5.61.3",
|
||||
"@tanstack/react-query-devtools": "5.61.3",
|
||||
"@types/node": "22.9.3",
|
||||
"@pandacss/dev": "0.51.1",
|
||||
"@tanstack/eslint-plugin-query": "5.64.2",
|
||||
"@tanstack/react-query-devtools": "5.64.2",
|
||||
"@types/node": "22.10.7",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.15.0",
|
||||
"@typescript-eslint/parser": "8.15.0",
|
||||
"@vitejs/plugin-react": "4.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.21.0",
|
||||
"@typescript-eslint/parser": "8.21.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-config-prettier": "10.0.1",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.0.0",
|
||||
"eslint-plugin-react-refresh": "0.4.14",
|
||||
"postcss": "8.4.49",
|
||||
"prettier": "3.3.3",
|
||||
"typescript": "5.7.2",
|
||||
"vite": "5.4.11",
|
||||
"vite-tsconfig-paths": "5.1.3"
|
||||
"eslint-plugin-react-hooks": "5.1.0",
|
||||
"eslint-plugin-react-refresh": "0.4.18",
|
||||
"postcss": "8.5.1",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.0.10",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@ const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
justifyContent: 'normal',
|
||||
padding: '0 1rem',
|
||||
width: 'calc(100% - 2rem)',
|
||||
'@media(prefers-reduced-motion: reduce)': {
|
||||
_motionReduce: {
|
||||
opacity: 1,
|
||||
},
|
||||
'@media(prefers-reduced-motion: no-preference)': {
|
||||
_motionSafe: {
|
||||
opacity: 0,
|
||||
animation: '.5s ease-in fade 0s forwards',
|
||||
},
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
|
||||
import {
|
||||
ParticipantPlaceholder,
|
||||
usePersistentUserChoices,
|
||||
usePreviewTracks,
|
||||
type LocalUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { log } from '@livekit/components-core'
|
||||
import { defaultUserChoices } from '@livekit/components-core'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { useUser } from '@/features/auth'
|
||||
import React from 'react'
|
||||
import {
|
||||
facingModeFromLocalTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
} from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
||||
import { Field } from '@/primitives/Field'
|
||||
import { Button } from '@/primitives'
|
||||
|
||||
const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
export const Join = ({
|
||||
onSubmit,
|
||||
@@ -11,20 +30,349 @@ export const Join = ({
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { user } = useUser()
|
||||
const defaults: Partial<LocalUserChoices> = { username: user?.full_name }
|
||||
const persistUserChoices = true
|
||||
const joinLabel = t('join.joinLabel')
|
||||
const userLabel = t('join.usernameLabel')
|
||||
|
||||
const [userChoices, setUserChoices] = React.useState(defaultUserChoices)
|
||||
|
||||
// TODO: Remove and pipe `defaults` object directly into `usePersistentUserChoices` once we fully switch from type `LocalUserChoices` to `UserChoices`.
|
||||
const partialDefaults: Partial<LocalUserChoices> = {
|
||||
...(defaults.audioDeviceId !== undefined && {
|
||||
audioDeviceId: defaults.audioDeviceId,
|
||||
}),
|
||||
...(defaults.videoDeviceId !== undefined && {
|
||||
videoDeviceId: defaults.videoDeviceId,
|
||||
}),
|
||||
...(defaults.audioEnabled !== undefined && {
|
||||
audioEnabled: defaults.audioEnabled,
|
||||
}),
|
||||
...(defaults.videoEnabled !== undefined && {
|
||||
videoEnabled: defaults.videoEnabled,
|
||||
}),
|
||||
...(defaults.username !== undefined && { username: defaults.username }),
|
||||
}
|
||||
|
||||
const {
|
||||
userChoices: initialUserChoices,
|
||||
saveAudioInputDeviceId,
|
||||
saveAudioInputEnabled,
|
||||
saveVideoInputDeviceId,
|
||||
saveVideoInputEnabled,
|
||||
saveUsername,
|
||||
} = usePersistentUserChoices({
|
||||
defaults: partialDefaults,
|
||||
preventSave: !persistUserChoices,
|
||||
preventLoad: !persistUserChoices,
|
||||
})
|
||||
|
||||
// Initialize device settings
|
||||
const [audioEnabled, setAudioEnabled] = React.useState<boolean>(
|
||||
initialUserChoices.audioEnabled
|
||||
)
|
||||
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(
|
||||
initialUserChoices.videoEnabled
|
||||
)
|
||||
const [audioDeviceId, setAudioDeviceId] = React.useState<string>(
|
||||
initialUserChoices.audioDeviceId
|
||||
)
|
||||
const [videoDeviceId, setVideoDeviceId] = React.useState<string>(
|
||||
initialUserChoices.videoDeviceId
|
||||
)
|
||||
const [username, setUsername] = React.useState(initialUserChoices.username)
|
||||
|
||||
// Save user choices to persistent storage.
|
||||
React.useEffect(() => {
|
||||
saveAudioInputEnabled(audioEnabled)
|
||||
}, [audioEnabled, saveAudioInputEnabled])
|
||||
React.useEffect(() => {
|
||||
saveVideoInputEnabled(videoEnabled)
|
||||
}, [videoEnabled, saveVideoInputEnabled])
|
||||
React.useEffect(() => {
|
||||
saveAudioInputDeviceId(audioDeviceId)
|
||||
}, [audioDeviceId, saveAudioInputDeviceId])
|
||||
React.useEffect(() => {
|
||||
saveVideoInputDeviceId(videoDeviceId)
|
||||
}, [videoDeviceId, saveVideoInputDeviceId])
|
||||
React.useEffect(() => {
|
||||
saveUsername(username)
|
||||
}, [username, saveUsername])
|
||||
|
||||
const tracks = usePreviewTracks(
|
||||
{
|
||||
audio: audioEnabled
|
||||
? { deviceId: initialUserChoices.audioDeviceId }
|
||||
: false,
|
||||
video: videoEnabled
|
||||
? { deviceId: initialUserChoices.videoDeviceId }
|
||||
: false,
|
||||
},
|
||||
onError
|
||||
)
|
||||
|
||||
const videoEl = React.useRef(null)
|
||||
|
||||
const videoTrack = React.useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Video
|
||||
)[0] as LocalVideoTrack,
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const audioTrack = React.useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Audio
|
||||
)[0] as LocalVideoTrack,
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const facingMode = React.useMemo(() => {
|
||||
if (videoTrack) {
|
||||
const { facingMode } = facingModeFromLocalTrack(videoTrack)
|
||||
return facingMode
|
||||
} else {
|
||||
return 'undefined'
|
||||
}
|
||||
}, [videoTrack])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (videoEl.current && videoTrack) {
|
||||
videoTrack.unmute()
|
||||
videoTrack.attach(videoEl.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
videoTrack?.detach()
|
||||
}
|
||||
}, [videoTrack])
|
||||
|
||||
const [isValid, setIsValid] = React.useState<boolean>()
|
||||
|
||||
const handleValidation = React.useCallback((values: LocalUserChoices) => {
|
||||
return values.username !== ''
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const newUserChoices = {
|
||||
username,
|
||||
videoEnabled,
|
||||
videoDeviceId,
|
||||
audioEnabled,
|
||||
audioDeviceId,
|
||||
}
|
||||
setUserChoices(newUserChoices)
|
||||
setIsValid(handleValidation(newUserChoices))
|
||||
}, [
|
||||
username,
|
||||
videoEnabled,
|
||||
handleValidation,
|
||||
audioEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
])
|
||||
|
||||
function handleSubmit() {
|
||||
if (handleValidation(userChoices)) {
|
||||
if (typeof onSubmit === 'function') {
|
||||
onSubmit(userChoices)
|
||||
}
|
||||
} else {
|
||||
log.warn('Validation failed with: ', userChoices)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<CenteredContent title={t('join.heading')}>
|
||||
<PreJoin
|
||||
persistUserChoices
|
||||
onSubmit={onSubmit}
|
||||
micLabel={t('join.audioinput.label')}
|
||||
camLabel={t('join.videoinput.label')}
|
||||
joinLabel={t('join.joinLabel')}
|
||||
userLabel={t('join.usernameLabel')}
|
||||
defaults={{ username: user?.full_name }}
|
||||
/>
|
||||
</CenteredContent>
|
||||
<Screen footer={false}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
lg: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
height: 'auto',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2rem',
|
||||
padding: '0 2rem',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
lg: {
|
||||
flexDirection: 'row',
|
||||
width: 'auto',
|
||||
height: '570px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
lg: {
|
||||
width: '740px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
borderRadius: '1rem',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: 16 / 9,
|
||||
'--tw-shadow':
|
||||
'0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a',
|
||||
'--tw-shadow-colored':
|
||||
'0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
|
||||
boxShadow:
|
||||
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
|
||||
})}
|
||||
>
|
||||
{videoTrack && (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
ref={videoEl}
|
||||
width="1280"
|
||||
height="720"
|
||||
data-lk-facing-mode={facingMode}
|
||||
className={css({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transform: 'rotateY(180deg)',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{(!videoTrack || !videoEnabled) && (
|
||||
<div
|
||||
id="container"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#000',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
})}
|
||||
>
|
||||
<ParticipantPlaceholder
|
||||
className={css({
|
||||
maxWidth: '100%',
|
||||
height: '70%',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="lk-button-group-container"></div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1.5rem',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
initialState={audioEnabled}
|
||||
track={audioTrack}
|
||||
initialDeviceId={audioDeviceId}
|
||||
onChange={(enabled) => setAudioEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
setAudioDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
initialState={videoEnabled}
|
||||
track={videoTrack}
|
||||
initialDeviceId={videoDeviceId}
|
||||
onChange={(enabled) => {
|
||||
setVideoEnabled(enabled)
|
||||
}}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
setVideoDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
flexShrink: 0,
|
||||
padding: '0',
|
||||
sm: {
|
||||
width: '448px',
|
||||
padding: '0 3rem 9rem 3rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<form
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<H lvl={1} className={css({ marginBottom: 0 })}>
|
||||
{t('join.heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="text"
|
||||
label={userLabel}
|
||||
defaultValue={username}
|
||||
onChange={(value) => setUsername(value)}
|
||||
validate={(value) => {
|
||||
return !value ? <p>{t('join.errors.usernameEmpty')}</p> : null
|
||||
}}
|
||||
className={css({
|
||||
width: '100%',
|
||||
})}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
maxLength={50}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={'primary'}
|
||||
onPress={handleSubmit}
|
||||
isDisabled={!isValid}
|
||||
fullWidth
|
||||
>
|
||||
{joinLabel}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ import { Text, P, ToggleButton, Div, H } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import {
|
||||
BackgroundBlur,
|
||||
BackgroundOptions,
|
||||
ProcessorWrapper,
|
||||
BackgroundTransformer,
|
||||
} from '@livekit/track-processors'
|
||||
BackgroundBlurFactory,
|
||||
BackgroundBlurProcessorInterface,
|
||||
} from './blur/index'
|
||||
|
||||
const Information = styled('div', {
|
||||
base: {
|
||||
@@ -27,6 +25,8 @@ enum BlurRadius {
|
||||
NORMAL = 10,
|
||||
}
|
||||
|
||||
const isSupported = BackgroundBlurFactory.isSupported()
|
||||
|
||||
export const Effects = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
||||
const { isCameraEnabled, cameraTrack, localParticipant } =
|
||||
@@ -37,15 +37,12 @@ export const Effects = () => {
|
||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
||||
|
||||
const getProcessor = () => {
|
||||
return localCameraTrack?.getProcessor() as ProcessorWrapper<BackgroundOptions>
|
||||
return localCameraTrack?.getProcessor() as BackgroundBlurProcessorInterface
|
||||
}
|
||||
|
||||
const getBlurRadius = (): BlurRadius => {
|
||||
const processor = getProcessor()
|
||||
return (
|
||||
(processor?.transformer as BackgroundTransformer)?.options?.blurRadius ||
|
||||
BlurRadius.NONE
|
||||
)
|
||||
return processor?.options.blurRadius || BlurRadius.NONE
|
||||
}
|
||||
|
||||
const toggleBlur = async (blurRadius: number) => {
|
||||
@@ -61,9 +58,11 @@ export const Effects = () => {
|
||||
if (blurRadius == currentBlurRadius && processor) {
|
||||
await localCameraTrack.stopProcessor()
|
||||
} else if (!processor) {
|
||||
await localCameraTrack.setProcessor(BackgroundBlur(blurRadius))
|
||||
await localCameraTrack.setProcessor(
|
||||
BackgroundBlurFactory.getProcessor({ blurRadius })!
|
||||
)
|
||||
} else {
|
||||
await processor?.updateTransformerOptions({ blurRadius })
|
||||
processor?.update({ blurRadius })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying blur:', error)
|
||||
@@ -147,7 +146,7 @@ export const Effects = () => {
|
||||
>
|
||||
{t('heading')}
|
||||
</H>
|
||||
{ProcessorWrapper.isSupported ? (
|
||||
{isSupported ? (
|
||||
<HStack>
|
||||
<ToggleButton
|
||||
size={'sm'}
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import posthog from 'posthog-js'
|
||||
import {
|
||||
FilesetResolver,
|
||||
ImageSegmenter,
|
||||
ImageSegmenterResult,
|
||||
} from '@mediapipe/tasks-vision'
|
||||
import {
|
||||
CLEAR_TIMEOUT,
|
||||
SET_TIMEOUT,
|
||||
TIMEOUT_TICK,
|
||||
timerWorkerScript,
|
||||
} from './TimerWorker'
|
||||
import { BackgroundBlurProcessorInterface, BackgroundOptions } from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
const PROCESSING_HEIGHT = 144
|
||||
|
||||
const SEGMENTATION_MASK_CANVAS_ID = 'background-blur-local-segmentation'
|
||||
const BLUR_CANVAS_ID = 'background-blur-local'
|
||||
|
||||
const DEFAULT_BLUR = '10'
|
||||
|
||||
/**
|
||||
* This implementation of video blurring is made to be run on CPU for browser that are
|
||||
* not compatible with track-processor-js.
|
||||
*
|
||||
* It also make possible to run blurring on browser that does not implement MediaStreamTrackGenerator and
|
||||
* MediaStreamTrackProcessor.
|
||||
*/
|
||||
export class BackgroundBlurCustomProcessor
|
||||
implements BackgroundBlurProcessorInterface
|
||||
{
|
||||
options: BackgroundOptions
|
||||
name: string
|
||||
processedTrack?: MediaStreamTrack | undefined
|
||||
|
||||
source?: MediaStreamTrack
|
||||
sourceSettings?: MediaTrackSettings
|
||||
videoElement?: HTMLVideoElement
|
||||
videoElementLoaded?: boolean
|
||||
|
||||
// Canvas containg the video processing result, of which we extract as stream.
|
||||
outputCanvas?: HTMLCanvasElement
|
||||
outputCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
imageSegmenter?: ImageSegmenter
|
||||
imageSegmenterResult?: ImageSegmenterResult
|
||||
|
||||
// Canvas used for resizing video source and projecting mask.
|
||||
segmentationMaskCanvas?: HTMLCanvasElement
|
||||
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
// Mask containg the inference result.
|
||||
segmentationMask?: ImageData
|
||||
|
||||
// The resized image of the video source.
|
||||
sourceImageData?: ImageData
|
||||
|
||||
timerWorker?: Worker
|
||||
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.name = 'blur'
|
||||
this.options = opts
|
||||
}
|
||||
|
||||
static get isSupported() {
|
||||
return navigator.userAgent.toLowerCase().includes('firefox')
|
||||
}
|
||||
|
||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||
if (!opts.element) {
|
||||
throw new Error('Element is required for processing')
|
||||
}
|
||||
|
||||
this.source = opts.track as MediaStreamTrack
|
||||
this.sourceSettings = this.source!.getSettings()
|
||||
this.videoElement = opts.element as HTMLVideoElement
|
||||
|
||||
this._createMainCanvas()
|
||||
this._createMaskCanvas()
|
||||
|
||||
const stream = this.outputCanvas!.captureStream()
|
||||
const tracks = stream.getVideoTracks()
|
||||
if (tracks.length == 0) {
|
||||
throw new Error('No tracks found for processing')
|
||||
}
|
||||
this.processedTrack = tracks[0]
|
||||
|
||||
this.segmentationMask = new ImageData(PROCESSING_WIDTH, PROCESSING_HEIGHT)
|
||||
await this.initSegmenter()
|
||||
this._initWorker()
|
||||
|
||||
posthog.capture('firefox-blurring-init')
|
||||
}
|
||||
|
||||
update(opts: BackgroundOptions): void {
|
||||
this.options = opts
|
||||
}
|
||||
|
||||
_initWorker() {
|
||||
this.timerWorker = new Worker(timerWorkerScript, {
|
||||
name: 'Blurring',
|
||||
})
|
||||
this.timerWorker.onmessage = (data) => this.onTimerMessage(data)
|
||||
// When hiding camera then showing it again, the onloadeddata callback is not fired again.
|
||||
if (this.videoElementLoaded) {
|
||||
this.timerWorker!.postMessage({
|
||||
id: SET_TIMEOUT,
|
||||
timeMs: 1000 / 30,
|
||||
})
|
||||
} else {
|
||||
this.videoElement!.onloadeddata = () => {
|
||||
this.videoElementLoaded = true
|
||||
this.timerWorker!.postMessage({
|
||||
id: SET_TIMEOUT,
|
||||
timeMs: 1000 / 30,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTimerMessage(response: { data: { id: number } }) {
|
||||
if (response.data.id === TIMEOUT_TICK) {
|
||||
this.process()
|
||||
}
|
||||
}
|
||||
|
||||
async initSegmenter() {
|
||||
const vision = await FilesetResolver.forVisionTasks(
|
||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
||||
)
|
||||
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
|
||||
baseOptions: {
|
||||
modelAssetPath:
|
||||
'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/float16/latest/selfie_segmenter_landscape.tflite',
|
||||
delegate: 'CPU', // Use CPU for Firefox.
|
||||
},
|
||||
runningMode: 'VIDEO',
|
||||
outputCategoryMask: true,
|
||||
outputConfidenceMasks: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the source video to the processing resolution.
|
||||
*/
|
||||
async sizeSource() {
|
||||
this.segmentationMaskCanvasCtx?.drawImage(
|
||||
this.videoElement!,
|
||||
0,
|
||||
0,
|
||||
this.videoElement!.videoWidth,
|
||||
this.videoElement!.videoHeight,
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT
|
||||
)
|
||||
|
||||
this.sourceImageData = this.segmentationMaskCanvasCtx?.getImageData(
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_WIDTH
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the segmentation.
|
||||
*/
|
||||
async segment() {
|
||||
const startTimeMs = performance.now()
|
||||
return new Promise<void>((resolve) => {
|
||||
this.imageSegmenter!.segmentForVideo(
|
||||
this.sourceImageData!,
|
||||
startTimeMs,
|
||||
(result: ImageSegmenterResult) => {
|
||||
this.imageSegmenterResult = result
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: future improvement with WebGL.
|
||||
*/
|
||||
async blur() {
|
||||
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
|
||||
for (let i = 0; i < mask.length; ++i) {
|
||||
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
|
||||
}
|
||||
|
||||
this.segmentationMaskCanvasCtx!.putImageData(this.segmentationMask!, 0, 0)
|
||||
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'copy'
|
||||
this.outputCanvasCtx!.filter = 'blur(8px)'
|
||||
|
||||
this.outputCanvasCtx!.drawImage(
|
||||
this.segmentationMaskCanvas!,
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT,
|
||||
0,
|
||||
0,
|
||||
this.videoElement!.videoWidth,
|
||||
this.videoElement!.videoHeight
|
||||
)
|
||||
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'source-in'
|
||||
this.outputCanvasCtx!.filter = 'none'
|
||||
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
|
||||
|
||||
this.outputCanvasCtx!.globalCompositeOperation = 'destination-over'
|
||||
this.outputCanvasCtx!.filter = `blur(${this.options.blurRadius ?? DEFAULT_BLUR}px)`
|
||||
this.outputCanvasCtx!.drawImage(this.videoElement!, 0, 0)
|
||||
}
|
||||
|
||||
async process() {
|
||||
await this.sizeSource()
|
||||
await this.segment()
|
||||
await this.blur()
|
||||
this.timerWorker!.postMessage({
|
||||
id: SET_TIMEOUT,
|
||||
timeMs: 1000 / 30,
|
||||
})
|
||||
}
|
||||
|
||||
_createMainCanvas() {
|
||||
this.outputCanvas = document.querySelector(
|
||||
'canvas#background-blur-local'
|
||||
) as HTMLCanvasElement
|
||||
if (!this.outputCanvas) {
|
||||
this.outputCanvas = this._createCanvas(
|
||||
BLUR_CANVAS_ID,
|
||||
this.sourceSettings!.width!,
|
||||
this.sourceSettings!.height!
|
||||
)
|
||||
}
|
||||
this.outputCanvasCtx = this.outputCanvas.getContext('2d')!
|
||||
}
|
||||
|
||||
_createMaskCanvas() {
|
||||
this.segmentationMaskCanvas = document.querySelector(
|
||||
`#${SEGMENTATION_MASK_CANVAS_ID}`
|
||||
) as HTMLCanvasElement
|
||||
if (!this.segmentationMaskCanvas) {
|
||||
this.segmentationMaskCanvas = this._createCanvas(
|
||||
SEGMENTATION_MASK_CANVAS_ID,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT
|
||||
)
|
||||
}
|
||||
this.segmentationMaskCanvasCtx =
|
||||
this.segmentationMaskCanvas.getContext('2d')!
|
||||
}
|
||||
|
||||
_createCanvas(id: string, width: number, height: number) {
|
||||
const element = document.createElement('canvas')
|
||||
element.setAttribute('id', id)
|
||||
element.setAttribute('width', '' + width)
|
||||
element.setAttribute('height', '' + height)
|
||||
return element
|
||||
}
|
||||
|
||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||
await this.destroy()
|
||||
return this.init(opts)
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.timerWorker?.postMessage({
|
||||
id: CLEAR_TIMEOUT,
|
||||
})
|
||||
|
||||
this.timerWorker?.terminate()
|
||||
this.imageSegmenter?.close()
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
BackgroundBlur,
|
||||
BackgroundTransformer,
|
||||
ProcessorWrapper,
|
||||
} from '@livekit/track-processors'
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import { BackgroundBlurProcessorInterface, BackgroundOptions } from '.'
|
||||
|
||||
/**
|
||||
* This is simply a wrapper around track-processor-js Processor
|
||||
* in order to be compatible with a common interface BackgroundBlurProcessorInterface
|
||||
* used accross the project.
|
||||
*/
|
||||
export class BackgroundBlurTrackProcessorJsWrapper
|
||||
implements BackgroundBlurProcessorInterface
|
||||
{
|
||||
name: string = 'Blur'
|
||||
|
||||
processor: ProcessorWrapper<BackgroundOptions>
|
||||
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.processor = BackgroundBlur(opts.blurRadius)
|
||||
}
|
||||
|
||||
async init(opts: ProcessorOptions<Track.Kind>) {
|
||||
return this.processor.init(opts)
|
||||
}
|
||||
|
||||
async restart(opts: ProcessorOptions<Track.Kind>) {
|
||||
return this.processor.restart(opts)
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
return this.processor.destroy()
|
||||
}
|
||||
|
||||
update(opts: BackgroundOptions): void {
|
||||
this.processor.updateTransformerOptions(opts)
|
||||
}
|
||||
|
||||
get processedTrack() {
|
||||
return this.processor.processedTrack
|
||||
}
|
||||
|
||||
get options() {
|
||||
return (this.processor.transformer as BackgroundTransformer).options
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* From https://github.com/jitsi/jitsi-meet
|
||||
*/
|
||||
|
||||
/**
|
||||
* SET_TIMEOUT constant is used to set interval and it is set in
|
||||
* the id property of the request.data property. TimeMs property must
|
||||
* also be set.
|
||||
*
|
||||
* ```
|
||||
* //Request.data example:
|
||||
* {
|
||||
* id: SET_TIMEOUT,
|
||||
* timeMs: 33
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const SET_TIMEOUT = 1
|
||||
|
||||
/**
|
||||
* CLEAR_TIMEOUT constant is used to clear the interval and it is set in
|
||||
* the id property of the request.data property.
|
||||
*
|
||||
* ```
|
||||
* {
|
||||
* id: CLEAR_TIMEOUT
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const CLEAR_TIMEOUT = 2
|
||||
|
||||
/**
|
||||
* TIMEOUT_TICK constant is used as response and it is set in the id property.
|
||||
*
|
||||
* ```
|
||||
* {
|
||||
* id: TIMEOUT_TICK
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const TIMEOUT_TICK = 3
|
||||
|
||||
/**
|
||||
* The following code is needed as string to create a URL from a Blob.
|
||||
* The URL is then passed to a WebWorker. Reason for this is to enable
|
||||
* use of setInterval that is not throttled when tab is inactive.
|
||||
*/
|
||||
const code = `
|
||||
var timer;
|
||||
|
||||
onmessage = function(request) {
|
||||
switch (request.data.id) {
|
||||
case ${SET_TIMEOUT}: {
|
||||
timer = setTimeout(() => {
|
||||
postMessage({ id: ${TIMEOUT_TICK} });
|
||||
}, request.data.timeMs);
|
||||
break;
|
||||
}
|
||||
case ${CLEAR_TIMEOUT}: {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
`
|
||||
|
||||
export const timerWorkerScript = URL.createObjectURL(
|
||||
new Blob([code], { type: 'application/javascript' })
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ProcessorWrapper } from '@livekit/track-processors'
|
||||
import { Track, TrackProcessor } from 'livekit-client'
|
||||
import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper'
|
||||
import { BackgroundBlurCustomProcessor } from './BackgroundBlurCustomProcessor'
|
||||
|
||||
export type BackgroundOptions = {
|
||||
blurRadius?: number
|
||||
}
|
||||
|
||||
export interface BackgroundBlurProcessorInterface
|
||||
extends TrackProcessor<Track.Kind> {
|
||||
update(opts: BackgroundOptions): void
|
||||
options: BackgroundOptions
|
||||
}
|
||||
|
||||
export class BackgroundBlurFactory {
|
||||
static isSupported() {
|
||||
return (
|
||||
ProcessorWrapper.isSupported || BackgroundBlurCustomProcessor.isSupported
|
||||
)
|
||||
}
|
||||
|
||||
static getProcessor(opts: BackgroundOptions) {
|
||||
if (ProcessorWrapper.isSupported) {
|
||||
return new BackgroundBlurTrackProcessorJsWrapper(opts)
|
||||
}
|
||||
if (BackgroundBlurCustomProcessor.isSupported) {
|
||||
return new BackgroundBlurCustomProcessor(opts)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Button } from '@/primitives'
|
||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||
import { RiCameraSwitchLine } from '@remixicon/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ButtonProps } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
enum FacingMode {
|
||||
USER = 'user',
|
||||
ENVIRONMENT = 'environment',
|
||||
}
|
||||
|
||||
export const CameraSwitchButton = (props: Partial<ButtonProps>) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
|
||||
const { devices, activeDeviceId, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({
|
||||
kind: 'videoinput',
|
||||
requestPermissions: true,
|
||||
})
|
||||
|
||||
// getCapabilities type is not available.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getDeviceFacingMode = (device: any): string[] => {
|
||||
if (!device.getCapabilities) {
|
||||
return []
|
||||
}
|
||||
const capabilities = device.getCapabilities()
|
||||
if (!capabilities) {
|
||||
return []
|
||||
}
|
||||
if (typeof capabilities.facingMode !== 'object') {
|
||||
return []
|
||||
}
|
||||
return capabilities.facingMode
|
||||
}
|
||||
|
||||
const detectCurrentFacingMode = (): FacingMode | null => {
|
||||
if (!activeDeviceId) {
|
||||
return null
|
||||
}
|
||||
const activeDevice = devices.find(
|
||||
(device) => device.deviceId === activeDeviceId
|
||||
)
|
||||
if (!activeDevice) {
|
||||
return null
|
||||
}
|
||||
const facingMode = getDeviceFacingMode(activeDevice)
|
||||
if (facingMode.indexOf(FacingMode.USER) >= 0) {
|
||||
return FacingMode.USER
|
||||
}
|
||||
if (facingMode.indexOf(FacingMode.ENVIRONMENT) >= 0) {
|
||||
return FacingMode.ENVIRONMENT
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const guessCurrentFacingMode = () => {
|
||||
const facingMode = detectCurrentFacingMode()
|
||||
if (facingMode) {
|
||||
return facingMode
|
||||
}
|
||||
// We consider by default if we have no clue that the user camera is used.
|
||||
return FacingMode.USER
|
||||
}
|
||||
|
||||
const [facingMode, setFacingMode] = useState<FacingMode | null>()
|
||||
|
||||
/**
|
||||
* Before setting the initial value of facingMode we need to wait for devices to
|
||||
* be loaded ( because in detectCurrentFacingMode we need to find the active device
|
||||
* in the devices list ), which is not the case at first render.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (devices.length == 0) {
|
||||
return
|
||||
}
|
||||
if (facingMode) {
|
||||
return
|
||||
}
|
||||
|
||||
setFacingMode(guessCurrentFacingMode())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [devices])
|
||||
|
||||
const getUserDevice = (
|
||||
facingMode: FacingMode
|
||||
): MediaDeviceInfo | undefined => {
|
||||
return devices.find((device) => {
|
||||
return getDeviceFacingMode(device).indexOf(facingMode) >= 0
|
||||
})
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
let target: FacingMode
|
||||
if (facingMode === FacingMode.USER) {
|
||||
target = FacingMode.ENVIRONMENT
|
||||
} else {
|
||||
target = FacingMode.USER
|
||||
}
|
||||
const device = getUserDevice(target)
|
||||
if (device) {
|
||||
setActiveMediaDevice(device.deviceId)
|
||||
setFacingMode(target)
|
||||
} else {
|
||||
console.error('Cannot get user device with facingMode ' + target)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
onPress={(e) => {
|
||||
toggle()
|
||||
props.onPress?.(e)
|
||||
}}
|
||||
variant="primaryTextDark"
|
||||
aria-label={t('options.items.switchCamera')}
|
||||
tooltip={t('options.items.switchCamera')}
|
||||
description={true}
|
||||
>
|
||||
<RiCameraSwitchLine size={20} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ export const OptionsButton = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu>
|
||||
<Menu variant="dark">
|
||||
<Button
|
||||
square
|
||||
variant="primaryDark"
|
||||
|
||||
+9
-9
@@ -3,7 +3,7 @@ import {
|
||||
RiMegaphoneLine,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { MenuItem, Menu as RACMenu, Section } from 'react-aria-components'
|
||||
import { MenuItem, Menu as RACMenu, MenuSection } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Separator } from '@/primitives/Separator'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
@@ -23,33 +23,33 @@ export const OptionsMenuItems = () => {
|
||||
width: '300px',
|
||||
}}
|
||||
>
|
||||
<Section>
|
||||
<MenuSection>
|
||||
<MenuItem
|
||||
onAction={() => toggleEffects()}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiAccountBoxLine size={20} />
|
||||
{t('effects')}
|
||||
</MenuItem>
|
||||
</Section>
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
<Section>
|
||||
<MenuSection>
|
||||
<MenuItem
|
||||
href={GRIST_FORM}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
{t('feedbacks')}
|
||||
{t('feedback')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() => setDialogOpen(true)}
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
{t('settings')}
|
||||
</MenuItem>
|
||||
</Section>
|
||||
</MenuSection>
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
|
||||
-1
@@ -118,7 +118,6 @@ export const ParticipantListItem = ({
|
||||
<HStack
|
||||
role="listitem"
|
||||
justify="space-between"
|
||||
key={participant.identity}
|
||||
id={participant.identity}
|
||||
className={css({
|
||||
padding: '0.25rem 0',
|
||||
|
||||
+8
-2
@@ -56,7 +56,10 @@ export const ParticipantsList = () => {
|
||||
heading={t('raisedHands')}
|
||||
participants={raisedHandParticipants}
|
||||
renderParticipant={(participant) => (
|
||||
<HandRaisedListItem participant={participant} />
|
||||
<HandRaisedListItem
|
||||
key={participant.identity}
|
||||
participant={participant}
|
||||
/>
|
||||
)}
|
||||
action={() => (
|
||||
<LowerAllHandsButton participants={raisedHandParticipants} />
|
||||
@@ -68,7 +71,10 @@ export const ParticipantsList = () => {
|
||||
heading={t('contributors')}
|
||||
participants={sortedParticipants}
|
||||
renderParticipant={(participant) => (
|
||||
<ParticipantListItem participant={participant} />
|
||||
<ParticipantListItem
|
||||
key={participant.identity}
|
||||
participant={participant}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Div>
|
||||
|
||||
@@ -46,9 +46,9 @@ export const ScreenShareToggle = ({
|
||||
{...props}
|
||||
>
|
||||
<Div position="relative">
|
||||
<RiRectangleLine size={28} />
|
||||
<RiRectangleLine size={24} />
|
||||
<Icon
|
||||
size={16}
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
|
||||
+63
-25
@@ -13,12 +13,14 @@ import {
|
||||
RiVideoOffLine,
|
||||
RiVideoOnLine,
|
||||
} from '@remixicon/react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client'
|
||||
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export type ToggleSource = Exclude<
|
||||
Track.Source,
|
||||
@@ -65,12 +67,22 @@ const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
|
||||
|
||||
type SelectToggleDeviceProps<T extends ToggleSource> =
|
||||
UseTrackToggleProps<T> & {
|
||||
track?: LocalAudioTrack | LocalVideoTrack | undefined
|
||||
initialDeviceId?: string
|
||||
onActiveDeviceChange: (deviceId: string) => void
|
||||
source: SelectToggleSource
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
menuVariant?: 'dark' | 'light'
|
||||
hideMenu?: boolean
|
||||
}
|
||||
|
||||
export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
track,
|
||||
initialDeviceId,
|
||||
onActiveDeviceChange,
|
||||
hideMenu,
|
||||
variant = 'primaryDark',
|
||||
menuVariant = 'light',
|
||||
...props
|
||||
}: SelectToggleDeviceProps<T>) => {
|
||||
const config = selectToggleDeviceConfig[props.source]
|
||||
@@ -81,7 +93,19 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
const trackProps = useTrackToggle(props)
|
||||
|
||||
const { devices, activeDeviceId, setActiveMediaDevice } =
|
||||
useMediaDeviceSelect({ kind: config.kind })
|
||||
useMediaDeviceSelect({ kind: config.kind, track })
|
||||
|
||||
/**
|
||||
* When providing only track outside of a room context, activeDeviceId is undefined.
|
||||
* So we need to initialize it with the initialDeviceId.
|
||||
* nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialDeviceId !== undefined) {
|
||||
setActiveMediaDevice(initialDeviceId)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setActiveMediaDevice])
|
||||
|
||||
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
|
||||
|
||||
@@ -92,29 +116,43 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
gap: '1px',
|
||||
})}
|
||||
>
|
||||
<ToggleDevice {...trackProps} config={config} />
|
||||
<Menu>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
variant={trackProps.enabled ? 'primaryDark' : 'error2'}
|
||||
>
|
||||
<RiArrowDownSLine />
|
||||
</Button>
|
||||
<MenuList
|
||||
items={devices.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))}
|
||||
selectedItem={activeDeviceId}
|
||||
onAction={(value) => {
|
||||
setActiveMediaDevice(value as string)
|
||||
onActiveDeviceChange(value as string)
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
<ToggleDevice
|
||||
{...trackProps}
|
||||
config={config}
|
||||
variant={variant}
|
||||
toggleButtonProps={{
|
||||
...(hideMenu
|
||||
? {
|
||||
groupPosition: undefined,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
{!hideMenu && (
|
||||
<Menu variant={menuVariant}>
|
||||
<Button
|
||||
tooltip={selectLabel}
|
||||
aria-label={selectLabel}
|
||||
groupPosition="right"
|
||||
square
|
||||
variant={trackProps.enabled ? variant : 'error2'}
|
||||
>
|
||||
<RiArrowDownSLine />
|
||||
</Button>
|
||||
<MenuList
|
||||
items={devices.map((d) => ({
|
||||
value: d.deviceId,
|
||||
label: d.label,
|
||||
}))}
|
||||
selectedItem={activeDeviceId}
|
||||
onAction={(value) => {
|
||||
setActiveMediaDevice(value as string)
|
||||
onActiveDeviceChange(value as string)
|
||||
}}
|
||||
variant={menuVariant}
|
||||
/>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,18 +6,28 @@ import { useTranslation } from 'react-i18next'
|
||||
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
|
||||
import useLongPress from '@/features/shortcuts/useLongPress'
|
||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||
import { useIsSpeaking, useLocalParticipant } from '@livekit/components-react'
|
||||
import {
|
||||
useIsSpeaking,
|
||||
useLocalParticipant,
|
||||
useMaybeRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
export type ToggleDeviceProps = {
|
||||
enabled: boolean
|
||||
toggle: () => void
|
||||
config: SelectToggleDeviceConfig
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
toggleButtonProps?: Partial<ToggleButtonProps>
|
||||
}
|
||||
|
||||
export const ToggleDevice = ({
|
||||
config,
|
||||
enabled,
|
||||
toggle,
|
||||
variant = 'primaryDark',
|
||||
toggleButtonProps,
|
||||
}: ToggleDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
@@ -48,24 +58,29 @@ export const ToggleDevice = ({
|
||||
|
||||
const Icon = enabled ? iconOn : iconOff
|
||||
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
|
||||
if (kind === 'audioinput' && pushToTalk) {
|
||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||
const context = useMaybeRoomContext()
|
||||
if (kind === 'audioinput' && pushToTalk && context) {
|
||||
return <ActiveSpeakerWrapper />
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
variant={enabled ? 'primaryDark' : 'error2'}
|
||||
variant={enabled ? variant : 'error2'}
|
||||
shySelected
|
||||
onPress={() => toggle()}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={toggleLabel}
|
||||
groupPosition="left"
|
||||
{...toggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveSpeakerWrapper = () => {
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const isSpeaking = useIsSpeaking(localParticipant)
|
||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function DesktopControlBar({
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
menuVariant="dark"
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
@@ -72,6 +73,7 @@ export function DesktopControlBar({
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
menuVariant="dark"
|
||||
/>
|
||||
{browserSupportsScreenSharing && (
|
||||
<ScreenShareToggle
|
||||
|
||||
@@ -23,6 +23,7 @@ import { LinkButton } from '@/primitives'
|
||||
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
|
||||
import { ResponsiveMenu } from './ResponsiveMenu'
|
||||
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
|
||||
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
||||
|
||||
export function MobileControlBar({
|
||||
onDeviceError,
|
||||
@@ -55,7 +56,7 @@ export function MobileControlBar({
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
width: '422px',
|
||||
width: '330px',
|
||||
})}
|
||||
>
|
||||
<LeaveButton />
|
||||
@@ -68,6 +69,7 @@ export function MobileControlBar({
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
@@ -78,6 +80,7 @@ export function MobileControlBar({
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<HandToggle />
|
||||
<Button
|
||||
@@ -154,8 +157,8 @@ export function MobileControlBar({
|
||||
<LinkButton
|
||||
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedbacks')}
|
||||
aria-label={t('options.items.feedbacks')}
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
@@ -174,6 +177,7 @@ export function MobileControlBar({
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
</Button>
|
||||
<CameraSwitchButton onPress={() => setIsMenuOpened(false)} />
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveMenu>
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
"toggleOff": "",
|
||||
"toggleOn": "",
|
||||
"usernameHint": "",
|
||||
"usernameLabel": ""
|
||||
"usernameLabel": "",
|
||||
"errors": {
|
||||
"usernameEmpty": ""
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "",
|
||||
"shareDialog": {
|
||||
@@ -80,10 +83,11 @@
|
||||
"options": {
|
||||
"buttonLabel": "",
|
||||
"items": {
|
||||
"feedbacks": "",
|
||||
"feedback": "",
|
||||
"settings": "",
|
||||
"username": "",
|
||||
"effects": ""
|
||||
"effects": "",
|
||||
"switchCamera": ""
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
|
||||
@@ -18,13 +18,16 @@
|
||||
"enable": "Enable microphone",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"heading": "Verify your settings",
|
||||
"heading": "Join the meeting",
|
||||
"joinLabel": "Join",
|
||||
"joinMeeting": "Join meeting",
|
||||
"toggleOff": "Click to turn off",
|
||||
"toggleOn": "Click to turn on",
|
||||
"usernameHint": "Shown to other participants",
|
||||
"usernameLabel": "Your name"
|
||||
"usernameLabel": "Your name",
|
||||
"errors": {
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
@@ -79,15 +82,16 @@
|
||||
"options": {
|
||||
"buttonLabel": "More Options",
|
||||
"items": {
|
||||
"feedbacks": "Give us feedbacks",
|
||||
"feedback": "Give us feedback",
|
||||
"settings": "Settings",
|
||||
"username": "Update Your Name",
|
||||
"effects": "Apply effects"
|
||||
"effects": "Apply effects",
|
||||
"switchCamera": "Switch camera"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "Your camera is disabled. Choose an option to enable it.",
|
||||
"notAvailable": "The blur effect will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome :(",
|
||||
"notAvailable": "The blur effect will be available soon on your browser. We're working on it! In the meantime, you can use Google Chrome for best performance or Firefox :(",
|
||||
"heading": "Blur",
|
||||
"blur": {
|
||||
"light": "Light blur",
|
||||
|
||||
@@ -18,13 +18,16 @@
|
||||
"enable": "Activer le micro",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"heading": "Vérifiez vos paramètres",
|
||||
"heading": "Rejoindre la réunion",
|
||||
"joinLabel": "Rejoindre",
|
||||
"joinMeeting": "Rejoindre la réjoindre",
|
||||
"toggleOff": "Cliquez pour désactiver",
|
||||
"toggleOn": "Cliquez pour activer",
|
||||
"usernameHint": "Affiché aux autres participants",
|
||||
"usernameLabel": "Votre nom"
|
||||
"usernameLabel": "Votre nom",
|
||||
"errors": {
|
||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||
"shareDialog": {
|
||||
@@ -79,15 +82,16 @@
|
||||
"options": {
|
||||
"buttonLabel": "Plus d'options",
|
||||
"items": {
|
||||
"feedbacks": "Partager votre avis",
|
||||
"feedback": "Partager votre avis",
|
||||
"settings": "Paramètres",
|
||||
"username": "Choisir votre nom",
|
||||
"effects": "Appliquer des effets"
|
||||
"effects": "Appliquer des effets",
|
||||
"switchCamera": "Changer de caméra"
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "Votre camera est désactivée. Choisissez une option pour l'activer.",
|
||||
"notAvailable": "L'effet de flou sera bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome :(",
|
||||
"notAvailable": "L'effet de flou sera bientôt disponible sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour une meilleure performance ou Firefox :(",
|
||||
"heading": "Flou",
|
||||
"blur": {
|
||||
"light": "Léger flou",
|
||||
|
||||
@@ -30,7 +30,7 @@ const box = cva({
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
default: {
|
||||
light: {
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'box.border',
|
||||
@@ -46,6 +46,10 @@ const box = cva({
|
||||
backgroundColor: 'box.bg',
|
||||
color: 'control.text',
|
||||
},
|
||||
dark: {
|
||||
backgroundColor: 'primaryDark.50',
|
||||
borderColord: 'primaryDark.50',
|
||||
},
|
||||
},
|
||||
size: {
|
||||
default: {
|
||||
@@ -57,7 +61,7 @@ const box = cva({
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
variant: 'light',
|
||||
size: 'default',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -25,11 +25,31 @@ const FieldWrapper = styled('div', {
|
||||
marginBottom: 'textfield',
|
||||
minWidth: 0,
|
||||
},
|
||||
variants: {
|
||||
noMargin: {
|
||||
true: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
fullWidth: {
|
||||
true: {
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const StyledLabel = styled(Label, {
|
||||
base: {
|
||||
display: 'block',
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
variants: {
|
||||
center: {
|
||||
true: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -80,6 +100,8 @@ type FieldProps<T extends object> = (
|
||||
) & {
|
||||
label: string
|
||||
description?: string
|
||||
wrapperProps?: React.ComponentProps<typeof FieldWrapper>
|
||||
labelProps?: React.ComponentProps<typeof StyledLabel>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +126,7 @@ export const Field = <T extends object>({
|
||||
}: FieldProps<T>) => {
|
||||
const LabelAndDescription = (
|
||||
<>
|
||||
<StyledLabel>{label}</StyledLabel>
|
||||
<StyledLabel {...props.labelProps}>{label}</StyledLabel>
|
||||
<FieldDescription slot="description">{description}</FieldDescription>
|
||||
</>
|
||||
)
|
||||
@@ -118,7 +140,7 @@ export const Field = <T extends object>({
|
||||
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<RACTextField
|
||||
validate={validate as unknown as TextFieldProps['validate']}
|
||||
{...(props as PartialTextFieldProps)}
|
||||
@@ -133,7 +155,7 @@ export const Field = <T extends object>({
|
||||
|
||||
if (type === 'checkbox') {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<Checkbox
|
||||
validate={validate as unknown as CheckboxProps['validate']}
|
||||
description={description}
|
||||
@@ -147,7 +169,7 @@ export const Field = <T extends object>({
|
||||
|
||||
if (type === 'checkboxGroup') {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<CheckboxGroup
|
||||
validate={validate as unknown as CheckboxGroupProps['validate']}
|
||||
{...(props as PartialCheckboxGroupProps)}
|
||||
@@ -170,7 +192,7 @@ export const Field = <T extends object>({
|
||||
|
||||
if (type === 'radioGroup') {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<RadioGroup
|
||||
validate={validate as unknown as RadioGroupProps['validate']}
|
||||
{...(props as PartialRadioGroupProps)}
|
||||
@@ -189,7 +211,7 @@ export const Field = <T extends object>({
|
||||
|
||||
if (type === 'select') {
|
||||
return (
|
||||
<FieldWrapper>
|
||||
<FieldWrapper {...props.wrapperProps}>
|
||||
<Select
|
||||
validate={validate as unknown as SelectProps<T>['validate']}
|
||||
{...(props as PartialSelectProps<T>)}
|
||||
|
||||
@@ -8,15 +8,17 @@ import { Box } from './Box'
|
||||
*/
|
||||
export const Menu = ({
|
||||
children,
|
||||
variant = 'light',
|
||||
}: {
|
||||
children: [trigger: ReactNode, menu: ReactNode]
|
||||
variant?: 'dark' | 'light'
|
||||
}) => {
|
||||
const [trigger, menu] = children
|
||||
return (
|
||||
<MenuTrigger>
|
||||
{trigger}
|
||||
<StyledPopover>
|
||||
<Box size="sm" type="popover">
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
{menu}
|
||||
</Box>
|
||||
</StyledPopover>
|
||||
|
||||
@@ -10,6 +10,7 @@ export const MenuList = <T extends string | number = string>({
|
||||
onAction,
|
||||
selectedItem,
|
||||
items = [],
|
||||
variant = 'light',
|
||||
...menuProps
|
||||
}: {
|
||||
onAction: (key: T) => void
|
||||
@@ -18,7 +19,11 @@ export const MenuList = <T extends string | number = string>({
|
||||
} & MenuProps<unknown> &
|
||||
RecipeVariantProps<typeof menuRecipe>) => {
|
||||
const [variantProps] = menuRecipe.splitVariantProps(menuProps)
|
||||
const classes = menuRecipe({ extraPadding: true, ...variantProps })
|
||||
const classes = menuRecipe({
|
||||
extraPadding: true,
|
||||
variant: variant,
|
||||
...variantProps,
|
||||
})
|
||||
return (
|
||||
<Menu
|
||||
selectionMode={selectedItem !== undefined ? 'single' : undefined}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Separator as RACSeparator } from 'react-aria-components'
|
||||
export const Separator = styled(RACSeparator, {
|
||||
base: {
|
||||
height: '1px',
|
||||
background: 'gray.400',
|
||||
background: 'primaryDark.200',
|
||||
margin: '4px 0',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -46,7 +46,11 @@ export const menuRecipe = sva({
|
||||
},
|
||||
},
|
||||
},
|
||||
dark: {},
|
||||
dark: {
|
||||
item: {
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
},
|
||||
extraPadding: {
|
||||
true: {
|
||||
@@ -67,6 +71,6 @@ export const menuRecipe = sva({
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'dark',
|
||||
variant: 'light',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -27,7 +27,6 @@ backend:
|
||||
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
|
||||
@@ -57,7 +56,7 @@ backend:
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_REGION_NAME: local
|
||||
RECORDING_ENABLE: True
|
||||
RECORDING_VERIFY_SSL: False
|
||||
RECORDING_VERIFY_SSL: True
|
||||
RECORDING_STORAGE_EVENT_ENABLE: True
|
||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
||||
@@ -88,6 +87,21 @@ backend:
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
name: certifi
|
||||
items:
|
||||
- key: cacert.pem
|
||||
path: cacert.pem
|
||||
|
||||
frontend:
|
||||
envVars:
|
||||
VITE_PORT: 8080
|
||||
|
||||
@@ -3,12 +3,17 @@ secrets:
|
||||
itemId: a25effec-eaea-4ce1-9ed8-3a3cc1c734db
|
||||
field: username
|
||||
podVariable: OIDC_RP_CLIENT_ID
|
||||
clusterSecretStore: bitwarden-login-visio
|
||||
clusterSecretStore: bitwarden-login-meet
|
||||
- name: oidcPass
|
||||
itemId: a25effec-eaea-4ce1-9ed8-3a3cc1c734db
|
||||
field: password
|
||||
podVariable: OIDC_RP_CLIENT_SECRET
|
||||
clusterSecretStore: bitwarden-login-visio
|
||||
clusterSecretStore: bitwarden-login-meet
|
||||
- name: brevoApiKey
|
||||
itemId: 99107889-6124-4436-97cc-a5193f28443f
|
||||
field: password
|
||||
podVariable: BREVO_API_KEY
|
||||
clusterSecretStore: bitwarden-login-meet
|
||||
image:
|
||||
repository: localhost:5001/meet-backend
|
||||
pullPolicy: Always
|
||||
@@ -73,11 +78,17 @@ backend:
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_REGION_NAME: local
|
||||
RECORDING_ENABLE: True
|
||||
RECORDING_VERIFY_SSL: False
|
||||
RECORDING_VERIFY_SSL: True
|
||||
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
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL: True
|
||||
BREVO_API_KEY:
|
||||
secretKeyRef:
|
||||
name: backend
|
||||
key: BREVO_API_KEY
|
||||
BREVO_API_CONTACT_LIST_IDS: 8
|
||||
|
||||
|
||||
migrate:
|
||||
@@ -104,6 +115,21 @@ backend:
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
name: certifi
|
||||
items:
|
||||
- key: cacert.pem
|
||||
path: cacert.pem
|
||||
|
||||
frontend:
|
||||
envVars:
|
||||
VITE_PORT: 8080
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
apiVersion: external-secrets.io/v1beta1
|
||||
kind: ClusterSecretStore
|
||||
metadata:
|
||||
name: bitwarden-login-visio
|
||||
namespace: {{ $.Release.Namespace | quote }}
|
||||
name: bitwarden-login-{{ $.Release.Namespace }}
|
||||
spec:
|
||||
provider:
|
||||
webhook:
|
||||
url: "http://bitwarden-cli-visio.meet.svc.cluster.local:8087/object/item/{{`{{ .remoteRef.key }}`}}"
|
||||
url: "http://bitwarden-cli-{{ $.Release.Namespace }}.{{ $.Release.Namespace }}.svc.cluster.local:8087/object/item/{{`{{ .remoteRef.key }}`}}"
|
||||
headers:
|
||||
Content-Type: application/json
|
||||
result:
|
||||
jsonPath: "$.data.login.{{`{{ .remoteRef.property }}`}}"
|
||||
---
|
||||
apiVersion: external-secrets.io/v1beta1
|
||||
kind: ClusterSecretStore
|
||||
metadata:
|
||||
name: bitwarden-fields-{{ $.Release.Namespace }}
|
||||
spec:
|
||||
provider:
|
||||
webhook:
|
||||
url: "http://bitwarden-cli-{{ $.Release.Namespace }}.{{ $.Release.Namespace }}.svc.cluster.local:8087/object/item/{{`{{ .remoteRef.key }}`}}"
|
||||
result:
|
||||
jsonPath: "$.data.fields[?@.name==\"{{`{{ .remoteRef.property }}`}}\"].value"
|
||||
---
|
||||
apiVersion: external-secrets.io/v1beta1
|
||||
kind: ClusterSecretStore
|
||||
metadata:
|
||||
name: bitwarden-attachments-{{ $.Release.Namespace }}
|
||||
spec:
|
||||
provider:
|
||||
webhook:
|
||||
url: "http://bitwarden-cli-{{ $.Release.Namespace }}.{{ $.Release.Namespace }}.svc.cluster.local:8087/object/attachment/{{`{{ .remoteRef.property }}`}}?itemid={{`{{ .remoteRef.key }}`}}"
|
||||
result: {}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: bitwarden-cli-visio
|
||||
name: bitwarden-cli-{{ $.Release.Namespace }}
|
||||
namespace: {{ $.Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: bitwarden-cli
|
||||
@@ -29,17 +29,17 @@ spec:
|
||||
- name: BW_HOST
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bitwarden-cli-visio
|
||||
name: bitwarden-cli-{{ $.Release.Namespace }}
|
||||
key: BW_HOST
|
||||
- name: BW_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bitwarden-cli-visio
|
||||
name: bitwarden-cli-{{ $.Release.Namespace }}
|
||||
key: BW_USERNAME
|
||||
- name: BW_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: bitwarden-cli-visio
|
||||
name: bitwarden-cli-{{ $.Release.Namespace }}
|
||||
key: BW_PASSWORD
|
||||
ports:
|
||||
- name: http
|
||||
@@ -74,7 +74,7 @@ spec:
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: bitwarden-cli-visio
|
||||
name: bitwarden-cli-{{ $.Release.Namespace }}
|
||||
namespace: {{ $.Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: bitwarden-cli
|
||||
|
||||
+50
-44
@@ -35,50 +35,53 @@
|
||||
|
||||
### backend
|
||||
|
||||
| Name | Description | Value |
|
||||
| ----------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `backend.command` | Override the backend container command | `[]` |
|
||||
| `backend.args` | Override the backend container args | `[]` |
|
||||
| `backend.replicas` | Amount of backend replicas | `3` |
|
||||
| `backend.shareProcessNamespace` | Enable share process namespace between containers | `false` |
|
||||
| `backend.sidecars` | Add sidecars containers to backend deployment | `[]` |
|
||||
| `backend.migrateJobAnnotations` | Annotations for the migrate job | `{}` |
|
||||
| `backend.securityContext` | Configure backend Pod security context | `nil` |
|
||||
| `backend.envVars` | Configure backend container environment variables | `undefined` |
|
||||
| `backend.envVars.BY_VALUE` | Example environment variable by setting value directly | |
|
||||
| `backend.envVars.FROM_CONFIGMAP.configMapKeyRef.name` | Name of a ConfigMap when configuring env vars from a ConfigMap | |
|
||||
| `backend.envVars.FROM_CONFIGMAP.configMapKeyRef.key` | Key within a ConfigMap when configuring env vars from a ConfigMap | |
|
||||
| `backend.envVars.FROM_SECRET.secretKeyRef.name` | Name of a Secret when configuring env vars from a Secret | |
|
||||
| `backend.envVars.FROM_SECRET.secretKeyRef.key` | Key within a Secret when configuring env vars from a Secret | |
|
||||
| `backend.podAnnotations` | Annotations to add to the backend Pod | `{}` |
|
||||
| `backend.service.type` | backend Service type | `ClusterIP` |
|
||||
| `backend.service.port` | backend Service listening port | `80` |
|
||||
| `backend.service.targetPort` | backend container listening port | `8000` |
|
||||
| `backend.service.annotations` | Annotations to add to the backend Service | `{}` |
|
||||
| `backend.migrate.command` | backend migrate command | `["python","manage.py","migrate","--no-input"]` |
|
||||
| `backend.migrate.restartPolicy` | backend migrate job restart policy | `Never` |
|
||||
| `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__heartbeat__` |
|
||||
| `backend.probes.liveness.targetPort` | Configure port for backend HTTP liveness probe | `undefined` |
|
||||
| `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `30` |
|
||||
| `backend.probes.liveness.initialDelaySeconds` | Configure timeout for backend liveness probe | `30` |
|
||||
| `backend.probes.startup.path` | Configure path for backend HTTP startup probe | `undefined` |
|
||||
| `backend.probes.startup.targetPort` | Configure port for backend HTTP startup probe | `undefined` |
|
||||
| `backend.probes.startup.initialDelaySeconds` | Configure initial delay for backend startup probe | `undefined` |
|
||||
| `backend.probes.startup.initialDelaySeconds` | Configure timeout for backend startup probe | `undefined` |
|
||||
| `backend.probes.readiness.path` | Configure path for backend HTTP readiness probe | `/__lbheartbeat__` |
|
||||
| `backend.probes.readiness.targetPort` | Configure port for backend HTTP readiness probe | `undefined` |
|
||||
| `backend.probes.readiness.initialDelaySeconds` | Configure initial delay for backend readiness probe | `30` |
|
||||
| `backend.probes.readiness.initialDelaySeconds` | Configure timeout for backend readiness probe | `30` |
|
||||
| `backend.resources` | Resource requirements for the backend container | `{}` |
|
||||
| `backend.nodeSelector` | Node selector for the backend Pod | `{}` |
|
||||
| `backend.tolerations` | Tolerations for the backend Pod | `[]` |
|
||||
| `backend.affinity` | Affinity for the backend Pod | `{}` |
|
||||
| `backend.persistence` | Additional volumes to create and mount on the backend. Used for debugging purposes | `{}` |
|
||||
| `backend.persistence.volume-name.size` | Size of the additional volume | |
|
||||
| `backend.persistence.volume-name.type` | Type of the additional volume, persistentVolumeClaim or emptyDir | |
|
||||
| `backend.persistence.volume-name.mountPath` | Path where the volume should be mounted to | |
|
||||
| `backend.extraVolumeMounts` | Additional volumes to mount on the backend. | `[]` |
|
||||
| `backend.extraVolumes` | Additional volumes to mount on the backend. | `[]` |
|
||||
| Name | Description | Value |
|
||||
| ----------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `backend.dpAnnotations` | Annotations to add to the backend Deployment | `{}` |
|
||||
| `backend.command` | Override the backend container command | `[]` |
|
||||
| `backend.args` | Override the backend container args | `[]` |
|
||||
| `backend.replicas` | Amount of backend replicas | `3` |
|
||||
| `backend.shareProcessNamespace` | Enable share process namespace between containers | `false` |
|
||||
| `backend.sidecars` | Add sidecars containers to backend deployment | `[]` |
|
||||
| `backend.migrateJobAnnotations` | Annotations for the migrate job | `{}` |
|
||||
| `backend.securityContext` | Configure backend Pod security context | `nil` |
|
||||
| `backend.envVars` | Configure backend container environment variables | `undefined` |
|
||||
| `backend.envVars.BY_VALUE` | Example environment variable by setting value directly | |
|
||||
| `backend.envVars.FROM_CONFIGMAP.configMapKeyRef.name` | Name of a ConfigMap when configuring env vars from a ConfigMap | |
|
||||
| `backend.envVars.FROM_CONFIGMAP.configMapKeyRef.key` | Key within a ConfigMap when configuring env vars from a ConfigMap | |
|
||||
| `backend.envVars.FROM_SECRET.secretKeyRef.name` | Name of a Secret when configuring env vars from a Secret | |
|
||||
| `backend.envVars.FROM_SECRET.secretKeyRef.key` | Key within a Secret when configuring env vars from a Secret | |
|
||||
| `backend.podAnnotations` | Annotations to add to the backend Pod | `{}` |
|
||||
| `backend.service.type` | backend Service type | `ClusterIP` |
|
||||
| `backend.service.port` | backend Service listening port | `80` |
|
||||
| `backend.service.targetPort` | backend container listening port | `8000` |
|
||||
| `backend.service.annotations` | Annotations to add to the backend Service | `{}` |
|
||||
| `backend.migrate.command` | backend migrate command | `["python","manage.py","migrate","--no-input"]` |
|
||||
| `backend.migrate.restartPolicy` | backend migrate job restart policy | `Never` |
|
||||
| `backend.createsuperuser.command` | backend migrate command | `["/bin/sh","-c","python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD\n"]` |
|
||||
| `backend.createsuperuser.restartPolicy` | backend migrate job restart policy | `Never` |
|
||||
| `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__heartbeat__` |
|
||||
| `backend.probes.liveness.targetPort` | Configure port for backend HTTP liveness probe | `undefined` |
|
||||
| `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `30` |
|
||||
| `backend.probes.liveness.initialDelaySeconds` | Configure timeout for backend liveness probe | `30` |
|
||||
| `backend.probes.startup.path` | Configure path for backend HTTP startup probe | `undefined` |
|
||||
| `backend.probes.startup.targetPort` | Configure port for backend HTTP startup probe | `undefined` |
|
||||
| `backend.probes.startup.initialDelaySeconds` | Configure initial delay for backend startup probe | `undefined` |
|
||||
| `backend.probes.startup.initialDelaySeconds` | Configure timeout for backend startup probe | `undefined` |
|
||||
| `backend.probes.readiness.path` | Configure path for backend HTTP readiness probe | `/__lbheartbeat__` |
|
||||
| `backend.probes.readiness.targetPort` | Configure port for backend HTTP readiness probe | `undefined` |
|
||||
| `backend.probes.readiness.initialDelaySeconds` | Configure initial delay for backend readiness probe | `30` |
|
||||
| `backend.probes.readiness.initialDelaySeconds` | Configure timeout for backend readiness probe | `30` |
|
||||
| `backend.resources` | Resource requirements for the backend container | `{}` |
|
||||
| `backend.nodeSelector` | Node selector for the backend Pod | `{}` |
|
||||
| `backend.tolerations` | Tolerations for the backend Pod | `[]` |
|
||||
| `backend.affinity` | Affinity for the backend Pod | `{}` |
|
||||
| `backend.persistence` | Additional volumes to create and mount on the backend. Used for debugging purposes | `{}` |
|
||||
| `backend.persistence.volume-name.size` | Size of the additional volume | |
|
||||
| `backend.persistence.volume-name.type` | Type of the additional volume, persistentVolumeClaim or emptyDir | |
|
||||
| `backend.persistence.volume-name.mountPath` | Path where the volume should be mounted to | |
|
||||
| `backend.extraVolumeMounts` | Additional volumes to mount on the backend. | `[]` |
|
||||
| `backend.extraVolumes` | Additional volumes to mount on the backend. | `[]` |
|
||||
|
||||
### frontend
|
||||
|
||||
@@ -87,6 +90,7 @@
|
||||
| `frontend.image.repository` | Repository to use to pull meet's frontend container image | `lasuite/meet-frontend` |
|
||||
| `frontend.image.tag` | meet's frontend container tag | `latest` |
|
||||
| `frontend.image.pullPolicy` | frontend container image pull policy | `IfNotPresent` |
|
||||
| `frontend.dpAnnotations` | Annotations to add to the frontend Deployment | `{}` |
|
||||
| `frontend.command` | Override the frontend container command | `[]` |
|
||||
| `frontend.args` | Override the frontend container args | `[]` |
|
||||
| `frontend.replicas` | Amount of frontend replicas | `3` |
|
||||
@@ -163,6 +167,7 @@
|
||||
|
||||
| Name | Description | Value |
|
||||
| ----------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------ |
|
||||
| `summary.dpAnnotations` | Annotations to add to the summary Deployment | `{}` |
|
||||
| `summary.command` | Override the summary container command | `[]` |
|
||||
| `summary.args` | Override the summary container args | `[]` |
|
||||
| `summary.replicas` | Amount of summary replicas | `1` |
|
||||
@@ -208,6 +213,7 @@
|
||||
|
||||
| Name | Description | Value |
|
||||
| ---------------------------------------------------- | --------------------------------------------------------------------------------- | ----------- |
|
||||
| `celery.dpAnnotations` | Annotations to add to the celery Deployment | `{}` |
|
||||
| `celery.command` | Override the celery container command | `[]` |
|
||||
| `celery.args` | Override the celery container args | `[]` |
|
||||
| `celery.replicas` | Amount of celery replicas | `1` |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
docker image ls | grep readme-generator-for-helm
|
||||
if [ "$?" -ne "0" ]; then
|
||||
@@ -7,4 +7,4 @@ if [ "$?" -ne "0" ]; then
|
||||
docker build -t readme-generator-for-helm:latest .
|
||||
cd $(dirname -- "${BASH_SOURCE[0]}")
|
||||
fi
|
||||
docker run --rm -it -v ./values.yaml:/app/values.yaml -v ./README.md:/app/README.md readme-generator-for-helm:latest readme-generator -v values.yaml -r README.md
|
||||
docker run --rm -it -v .:/source -w /source readme-generator-for-helm:latest readme-generator -v values.yaml -r README.md
|
||||
|
||||
@@ -5,6 +5,10 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
annotations:
|
||||
{{- with .Values.backend.dpAnnotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
|
||||
|
||||
@@ -5,6 +5,10 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
annotations:
|
||||
{{- with .Values.celery.dpAnnotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
|
||||
|
||||
@@ -5,6 +5,10 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
annotations:
|
||||
{{- with .Values.frontend.dpAnnotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
|
||||
|
||||
@@ -5,6 +5,10 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ $fullName }}
|
||||
annotations:
|
||||
{{- with .Values.summary.dpAnnotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
|
||||
|
||||
@@ -73,6 +73,8 @@ ingressAdmin:
|
||||
## @section backend
|
||||
|
||||
backend:
|
||||
## @param backend.dpAnnotations Annotations to add to the backend Deployment
|
||||
dpAnnotations: {}
|
||||
|
||||
## @param backend.command Override the backend container command
|
||||
command: []
|
||||
@@ -194,6 +196,9 @@ frontend:
|
||||
pullPolicy: IfNotPresent
|
||||
tag: "latest"
|
||||
|
||||
## @param frontend.dpAnnotations Annotations to add to the frontend Deployment
|
||||
dpAnnotations: {}
|
||||
|
||||
## @param frontend.command Override the frontend container command
|
||||
command: []
|
||||
|
||||
@@ -347,6 +352,9 @@ posthog:
|
||||
|
||||
summary:
|
||||
|
||||
## @param summary.dpAnnotations Annotations to add to the summary Deployment
|
||||
dpAnnotations: {}
|
||||
|
||||
## @param summary.command Override the summary container command
|
||||
command: []
|
||||
|
||||
@@ -439,6 +447,8 @@ summary:
|
||||
## @section celery
|
||||
|
||||
celery:
|
||||
## @param celery.dpAnnotations Annotations to add to the celery Deployment
|
||||
dpAnnotations: {}
|
||||
|
||||
## @param celery.command Override the celery container command
|
||||
command: []
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.11.0"
|
||||
"node": "22.13.0"
|
||||
},
|
||||
"repository": "https://github.com/numerique-gouv/meet",
|
||||
"author": "DINUM",
|
||||
|
||||
@@ -9,15 +9,15 @@ dependencies = [
|
||||
"pydantic-settings>=2.1.0",
|
||||
"celery==5.4.0",
|
||||
"redis==5.2.1",
|
||||
"minio==7.2.12",
|
||||
"openai==1.57.1",
|
||||
"minio==7.2.15",
|
||||
"openai==1.59.8",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk[fastapi, celery]==2.19.0",
|
||||
"sentry-sdk[fastapi, celery]==2.20.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.8.2",
|
||||
"ruff==0.9.2",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
Reference in New Issue
Block a user