mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-15 01:13:43 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7f9522943 |
@@ -22,9 +22,6 @@
|
||||
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
|
||||
extra-path=gitlint/
|
||||
|
||||
# Use "search" semantics for regexes (gitlint's future default)
|
||||
regex-style-search=True
|
||||
|
||||
# [title-max-length]
|
||||
# line-length=80
|
||||
|
||||
|
||||
@@ -10,17 +10,7 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
|
||||
|
||||
## [1.35.0] - 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) let users pick the recording encoding
|
||||
- ✨(backend) introduce a token exchange endpoint for iframe embeds
|
||||
- ⚡️(backend) send recording notification emails from a Celery task
|
||||
- ✨(backend) allow static feature flags without an analytics backend #1809
|
||||
- ⚡️(backend) add an opt-in psycopg connection pool
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -28,7 +18,6 @@ and this project adheres to
|
||||
- ⚡️(backend) reduce domain queries on the application token endpoint
|
||||
- ♻️️️(backend) use a dedicated auth scheme for LiveKit token auth
|
||||
- ♻️(all) stop relying on cookies for the lobby flow
|
||||
- ♿️(frontend) show a visible focus outline on menu items #1797
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -36,9 +25,6 @@ and this project adheres to
|
||||
- 🔒️(frontend) upgrade tiff to fix CVE-2026-4775
|
||||
- 🔒️(backend) bind accepted lobby entries to the current username
|
||||
- 🔒️(backend) sign lobby participant id
|
||||
- 🐛(frontend) omit the default speaker id sent to LiveKit
|
||||
- 🐛(frontend) play the speaker test when no output is picked
|
||||
- 🐛(frontend) keep the admin panel in place when changing a setting #1804
|
||||
|
||||
## [1.34.0] - 2026-10-07
|
||||
|
||||
|
||||
+13
-13
@@ -5,17 +5,17 @@ services:
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "127.0.0.1:15432:5432"
|
||||
- "15432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
- "6379:6379"
|
||||
|
||||
mailcatcher:
|
||||
image: sj26/mailcatcher:latest
|
||||
ports:
|
||||
- "127.0.0.1:1081:1080"
|
||||
- "1081:1080"
|
||||
|
||||
garage:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
- "127.0.0.1:8071:8000"
|
||||
- "8071:8000"
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
@@ -137,7 +137,7 @@ services:
|
||||
nginx:
|
||||
image: nginx:1.25
|
||||
ports:
|
||||
- "127.0.0.1:8083:8083"
|
||||
- "8083:8083"
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
@@ -159,7 +159,7 @@ services:
|
||||
VITE_APP_TITLE: "LaSuite Meet"
|
||||
image: meet:frontend-development
|
||||
ports:
|
||||
- "127.0.0.1:3000:8080"
|
||||
- "3000:8080"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
@@ -185,7 +185,7 @@ services:
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432"
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
|
||||
@@ -222,10 +222,10 @@ services:
|
||||
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "127.0.0.1:7881:7881"
|
||||
- "127.0.0.1:7882:7882/udp"
|
||||
- "127.0.0.1:3478:3478/udp"
|
||||
- "127.0.0.1:30000-30100:30000-30100/udp"
|
||||
- "7881:7881"
|
||||
- "7882:7882/udp"
|
||||
- "3478:3478/udp"
|
||||
- "30000-30100:30000-30100/udp"
|
||||
volumes:
|
||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||
depends_on:
|
||||
@@ -283,7 +283,7 @@ services:
|
||||
env_file:
|
||||
- env.d/development/summary
|
||||
ports:
|
||||
- "127.0.0.1:8001:8000"
|
||||
- "8001:8000"
|
||||
volumes:
|
||||
- ./src/summary:/app
|
||||
depends_on:
|
||||
|
||||
@@ -45,4 +45,4 @@ services:
|
||||
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
||||
keycloak:
|
||||
ports: !override
|
||||
- "127.0.0.1:8081:8080"
|
||||
- "8081:8080"
|
||||
|
||||
@@ -113,33 +113,12 @@ DJANGO_EMAIL_FROM=<your email address>
|
||||
|
||||
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
|
||||
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
|
||||
|
||||
|
||||
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
|
||||
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
|
||||
```
|
||||
|
||||
### Feature flags without an analytics backend
|
||||
|
||||
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
|
||||
|
||||
```env
|
||||
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
|
||||
```
|
||||
|
||||
You can also set it directly in `compose.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
|
||||
|
||||
## Step 3: Configure your firewall
|
||||
|
||||
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
|
||||
|
||||
@@ -318,17 +318,12 @@ These are the environmental options available on meet backend.
|
||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | Name of the database | meet |
|
||||
| DB_USER | User used to connect to database | dinum |
|
||||
| DB_PASSWORD | Password used to connect to the database | pass |
|
||||
| DB_HOST | Hostname of the database | localhost |
|
||||
| DB_PORT | Port to connect to database | 5432 |
|
||||
| DB_PSYCOPG_POOL_ENABLED | Enable the psycopg connection pool | false |
|
||||
| DB_PSYCOPG_POOL_MIN_SIZE | Connections each pool keeps open | 1 |
|
||||
| DB_PSYCOPG_POOL_MAX_SIZE | Maximum connections per pool | 3 |
|
||||
| DB_PSYCOPG_POOL_TIMEOUT | Seconds a request waits for a free pool connection before failing | 30 |
|
||||
| DB_PSYCOPG_POOL_MAX_IDLE | Seconds an idle connection above the minimum stays open | 600 |
|
||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
|
||||
@@ -362,7 +357,6 @@ These are the environmental options available on meet backend.
|
||||
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||
| DJANGO_EMAIL_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
|
||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||
| EMAIL_BRAND_NAME | Email branding name | |
|
||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||
@@ -421,8 +415,6 @@ These are the environmental options available on meet backend.
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
|
||||
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||
|
||||
@@ -8,5 +8,4 @@ DB_HOST=postgresql
|
||||
DB_NAME=meet
|
||||
DB_USER=dinum
|
||||
DB_PASSWORD=pass
|
||||
DB_PORT=5432
|
||||
DB_PSYCOPG_POOL_ENABLED=True
|
||||
DB_PORT=5432
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.35.0"
|
||||
version = "1.34.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.7.0",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.35.0"
|
||||
version = "1.34.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
|
||||
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
|
||||
|
||||
if not dotted_path:
|
||||
return NoOpAnalytics(**options)
|
||||
return NoOpAnalytics()
|
||||
|
||||
backend_class = import_string(dotted_path)
|
||||
return backend_class(**options)
|
||||
|
||||
@@ -53,23 +53,7 @@ class AnalyticsBackend(ABC):
|
||||
|
||||
|
||||
class NoOpAnalytics(AnalyticsBackend):
|
||||
"""
|
||||
Default backend: discards events and serves static feature flags.
|
||||
|
||||
Feature flags can be declared without any analytics provider, e.g.:
|
||||
|
||||
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
feature_flags: Mapping[str, bool | str | None] | None = None,
|
||||
**_kwargs: Any,
|
||||
) -> None:
|
||||
# Other settings are ignored, so that settings meant for another
|
||||
# backend never break an instance running without one.
|
||||
self._feature_flags = dict(feature_flags or {})
|
||||
"""Default backend: silently discards everything."""
|
||||
|
||||
def identify(self, user: User, properties=None) -> None:
|
||||
"""No-op: discards identify calls."""
|
||||
@@ -79,10 +63,3 @@ class NoOpAnalytics(AnalyticsBackend):
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""No-op: nothing to flush."""
|
||||
|
||||
def get_user_feature_flags(
|
||||
self,
|
||||
user: User,
|
||||
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||
"""Return the feature flags declared in settings, for any user."""
|
||||
return self._feature_flags
|
||||
|
||||
@@ -44,17 +44,6 @@ def get_frontend_configuration(request):
|
||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||
"encoding": {
|
||||
"is_custom_enabled": settings.RECORDING_CUSTOM_ENCODING_ENABLED,
|
||||
"available_resolutions": list(
|
||||
settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.keys()
|
||||
),
|
||||
"available_profiles": list(
|
||||
settings.RECORDING_ENCODING_AVAILABLE_PROFILES.keys()
|
||||
),
|
||||
"default_resolution": settings.RECORDING_ENCODING_DEFAULT_RESOLUTION,
|
||||
"default_profile": settings.RECORDING_ENCODING_DEFAULT_PROFILE,
|
||||
},
|
||||
},
|
||||
"background_image": {
|
||||
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-10-07 18:45
|
||||
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations, models
|
||||
from django.db.models.functions import Upper
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
('core', '0026_alter_application_scopes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name="user",
|
||||
index=models.Index(
|
||||
Upper("email"),
|
||||
name="user_email_upper_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -246,9 +246,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
name="unique_email_when_sub_is_null",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
|
||||
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import get_language, gettext, gettext_noop, override
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, gettext, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from celery.exceptions import OperationalError as CeleryOperationalError
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.tasks.mail import send_email
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,14 +68,10 @@ class NotificationService:
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""
|
||||
Queue an email notification to recording owners when their recording is ready.
|
||||
Send an email notification to recording owners when their recording is ready.
|
||||
|
||||
The email includes a direct link that redirects owners to a dedicated download
|
||||
page in the frontend where they can access their specific recording.
|
||||
|
||||
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
|
||||
this only does one DB query and enqueues one message per owner.
|
||||
Returns True when every email was queued (or sent, if Celery is disabled).
|
||||
"""
|
||||
|
||||
owner_accesses = (
|
||||
@@ -92,6 +88,10 @@ class NotificationService:
|
||||
return False
|
||||
|
||||
context = {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_name": recording.room.name,
|
||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
@@ -104,22 +104,37 @@ class NotificationService:
|
||||
# 2. The number of recipients per recording is typically small (not thousands)
|
||||
for access in owner_accesses:
|
||||
user = access.user
|
||||
local_created_at = recording.created_at.astimezone(user.timezone)
|
||||
try:
|
||||
send_email.delay(
|
||||
template="screen_recording",
|
||||
subject=gettext_noop("Your recording is ready"),
|
||||
recipients=[user.email],
|
||||
language=user.language or get_language(),
|
||||
context={
|
||||
**context,
|
||||
"recording_date": local_created_at.strftime("%Y-%m-%d"),
|
||||
"recording_time": local_created_at.strftime("%H:%M"),
|
||||
},
|
||||
language = user.language or get_language()
|
||||
with override(language):
|
||||
personalized_context = {
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%H:%M"),
|
||||
**context,
|
||||
}
|
||||
msg_html = render_to_string(
|
||||
"mail/html/screen_recording.html", personalized_context
|
||||
)
|
||||
except (smtplib.SMTPException, CeleryOperationalError) as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
msg_plain = render_to_string(
|
||||
"mail/text/screen_recording.txt", personalized_context
|
||||
)
|
||||
subject = gettext("Your recording is ready") # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[user.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
|
||||
return not has_failures
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
from core.tasks.connection_test import delete_connection_test_room
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.tasks.mail import send_email
|
||||
|
||||
__all__ = (
|
||||
"delete_connection_test_room",
|
||||
"process_file_deletion",
|
||||
"send_email",
|
||||
)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Send mail using celery task."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, gettext, override
|
||||
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_base_email_context() -> dict:
|
||||
"""Context shared by every email template (branding, support, domain)."""
|
||||
return {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
}
|
||||
|
||||
|
||||
@task(
|
||||
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
|
||||
retry_backoff=True,
|
||||
retry_backoff_max=600,
|
||||
retry_jitter=True,
|
||||
max_retries=5,
|
||||
)
|
||||
def send_email(
|
||||
*,
|
||||
template: str,
|
||||
subject: str,
|
||||
recipients: list[str],
|
||||
context: dict | None = None,
|
||||
language: str | None = None,
|
||||
):
|
||||
"""Render a mail template and send it.
|
||||
|
||||
Generic on purpose: any feature can reuse it with its own template and context.
|
||||
|
||||
Args:
|
||||
template: Template base name, e.g. "screen_recording". Both
|
||||
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
|
||||
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
|
||||
call site; it is translated here, in ``language``.
|
||||
recipients: Email addresses, all receiving the same rendered message.
|
||||
context: Template context. Must be JSON-serializable (str, int, list, dict…):
|
||||
pre-format dates, cast UUIDs to str. Merged over the base branding context.
|
||||
language: Language to render the email in. Defaults to the active language.
|
||||
|
||||
Raises:
|
||||
OSError: SMTP or network failure, after retries are exhausted (or
|
||||
immediately when Celery is disabled and the call runs synchronously).
|
||||
"""
|
||||
full_context = {**get_base_email_context(), **(context or {})}
|
||||
|
||||
with override(language or get_language()):
|
||||
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
|
||||
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
|
||||
translated_subject = gettext(subject)
|
||||
|
||||
send_mail(
|
||||
translated_subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
recipients,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
|
||||
@@ -1,124 +0,0 @@
|
||||
"""
|
||||
Unit tests for NoOpAnalytics and the analytics backend loader.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import pytest
|
||||
|
||||
from core.analytics import get_analytics
|
||||
from core.analytics.base import NoOpAnalytics
|
||||
from core.analytics.user_feature_flags import UserFeatureFlag
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_analytics_cache():
|
||||
"""Clear the cached backend before and after each test."""
|
||||
get_analytics.cache_clear()
|
||||
yield
|
||||
get_analytics.cache_clear()
|
||||
|
||||
|
||||
# ==============================
|
||||
# NoOpAnalytics
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_noop_has_no_feature_flags_by_default():
|
||||
"""Should keep every feature flag disabled when none is declared."""
|
||||
backend = NoOpAnalytics()
|
||||
user = UserFactory()
|
||||
|
||||
assert not backend.get_user_feature_flags(user)
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_noop_enables_declared_feature_flags():
|
||||
"""Should enable a feature flag declared as True."""
|
||||
backend = NoOpAnalytics(feature_flags={"summary-enabled": True})
|
||||
user = UserFactory()
|
||||
|
||||
assert backend.get_user_feature_flags(user) == {"summary-enabled": True}
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [False, None, "true"])
|
||||
def test_noop_only_enables_feature_flags_set_to_true(value):
|
||||
"""Should keep a feature flag disabled unless its value is exactly True."""
|
||||
backend = NoOpAnalytics(feature_flags={"summary-enabled": value})
|
||||
user = UserFactory()
|
||||
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_noop_ignores_unrelated_settings():
|
||||
"""Should accept settings meant for another backend without failing."""
|
||||
backend = NoOpAnalytics(api_key="my-key", host="https://eu.i.posthog.com")
|
||||
|
||||
assert not backend.get_user_feature_flags(UserFactory())
|
||||
|
||||
|
||||
# ==============================
|
||||
# get_analytics
|
||||
# ==============================
|
||||
|
||||
|
||||
def test_get_analytics_defaults_to_noop_without_feature_flags(settings):
|
||||
"""Should return a no-op backend with no feature flag by default."""
|
||||
settings.ANALYTICS_BACKEND = None
|
||||
settings.ANALYTICS_BACKEND_SETTINGS = {}
|
||||
|
||||
backend = get_analytics()
|
||||
|
||||
assert isinstance(backend, NoOpAnalytics)
|
||||
assert not backend.get_user_feature_flags(UserFactory())
|
||||
|
||||
|
||||
def test_get_analytics_passes_feature_flags_to_noop(settings):
|
||||
"""Should forward the feature flags when no backend is configured."""
|
||||
settings.ANALYTICS_BACKEND = None
|
||||
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||
|
||||
backend = get_analytics()
|
||||
|
||||
assert isinstance(backend, NoOpAnalytics)
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_get_analytics_instantiates_configured_backend_with_settings(settings):
|
||||
"""Should instantiate the configured backend with its settings."""
|
||||
settings.ANALYTICS_BACKEND = "core.analytics.base.NoOpAnalytics"
|
||||
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
|
||||
|
||||
backend = get_analytics()
|
||||
|
||||
assert isinstance(backend, NoOpAnalytics)
|
||||
assert (
|
||||
backend.is_user_feature_enabled(
|
||||
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
is True
|
||||
)
|
||||
@@ -6,7 +6,6 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
@@ -362,21 +361,18 @@ def test_api_files_create_file_race_condition():
|
||||
"""
|
||||
|
||||
def create_item(title):
|
||||
try:
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"title": title,
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "my_file.png",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"title": title,
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "my_file.png",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future1 = executor.submit(create_item, "my item 1")
|
||||
|
||||
@@ -5,7 +5,6 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import connection
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
@@ -296,12 +295,9 @@ def test_api_file_upload_ended_concurrent_calls_are_serialized(settings):
|
||||
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
|
||||
|
||||
def call_upload_ended():
|
||||
try:
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
finally:
|
||||
connection.close()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
|
||||
@@ -12,7 +12,6 @@ from unittest import mock
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
import pytest
|
||||
from celery.exceptions import OperationalError as CeleryOperationalError
|
||||
|
||||
from core import factories, models
|
||||
from core.analytics import UserFeatureFlag
|
||||
@@ -143,7 +142,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
|
||||
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
@@ -238,7 +237,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch(
|
||||
"core.tasks.mail.send_mail",
|
||||
"core.recording.event.notification.send_mail",
|
||||
side_effect=smtplib.SMTPException("SMTP Error"),
|
||||
) as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
@@ -248,60 +247,6 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
assert "notification could not be sent:" in caplog.text
|
||||
|
||||
|
||||
def test_notify_user_by_email_queues_generic_task(settings):
|
||||
"""One generic send_email task is queued per owner, with serializable args."""
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
|
||||
settings.RECORDING_EXPIRATION_DAYS = 7
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
recording.created_at = datetime.datetime(
|
||||
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
user = factories.UserFactory(
|
||||
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=user
|
||||
)
|
||||
|
||||
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
mock_delay.assert_called_once_with(
|
||||
template="screen_recording",
|
||||
subject="Your recording is ready",
|
||||
recipients=["franc@test.com"],
|
||||
language="fr-fr",
|
||||
context={
|
||||
"room_name": "Conference Room A",
|
||||
"recording_expiration_days": 7,
|
||||
"link": f"https://acme.com/recordings/{recording.id}",
|
||||
"recording_date": "2023-05-15",
|
||||
"recording_time": "16:30",
|
||||
},
|
||||
)
|
||||
# Celery's JSON serializer must accept every argument
|
||||
json.dumps(mock_delay.call_args.kwargs)
|
||||
|
||||
|
||||
def test_notify_user_by_email_broker_unavailable(caplog):
|
||||
"""A broker outage is reported as a failure without crashing the caller."""
|
||||
recording = factories.RecordingFactory()
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.send_email.delay",
|
||||
side_effect=CeleryOperationalError("broker down"),
|
||||
):
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
assert "notification could not be sent: broker down" in caplog.text
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Tests for the generic send_email task."""
|
||||
|
||||
# pylint: disable=unused-argument,redefined-outer-name
|
||||
|
||||
import smtplib
|
||||
from unittest import mock
|
||||
|
||||
from django.core.mail import get_connection
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tasks.mail import send_email
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def email_settings(settings):
|
||||
"""Branding settings shared by every email."""
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.EMAIL_DOMAIN = "acme.com"
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
return settings
|
||||
|
||||
|
||||
@mock.patch("core.tasks.mail.send_mail")
|
||||
@mock.patch("core.tasks.mail.render_to_string", side_effect=["<p>html</p>", "text"])
|
||||
def test_send_email_renders_template_and_sends(
|
||||
mock_render, mock_send_mail, email_settings
|
||||
):
|
||||
"""Both template variants are rendered with base + caller context, then sent."""
|
||||
send_email(
|
||||
template="some_template",
|
||||
subject="Any subject",
|
||||
recipients=["a@test.com", "b@test.com"],
|
||||
context={"foo": "bar", "brandname": "Overridden"},
|
||||
)
|
||||
|
||||
expected_context = {
|
||||
"brandname": "Overridden",
|
||||
"support_email": "support@acme.com",
|
||||
"logo_img": "https://acme.com/logo",
|
||||
"domain": "acme.com",
|
||||
"foo": "bar",
|
||||
}
|
||||
assert mock_render.call_args_list == [
|
||||
mock.call("mail/html/some_template.html", expected_context),
|
||||
mock.call("mail/text/some_template.txt", expected_context),
|
||||
]
|
||||
mock_send_mail.assert_called_once_with(
|
||||
"Any subject",
|
||||
"text",
|
||||
"notifications@acme.com",
|
||||
["a@test.com", "b@test.com"],
|
||||
html_message="<p>html</p>",
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.tasks.mail.send_mail")
|
||||
def test_send_email_translates_subject_in_requested_language(
|
||||
mock_send_mail, email_settings
|
||||
):
|
||||
"""The subject msgid is translated in the worker, in the recipient language."""
|
||||
send_email(
|
||||
template="screen_recording",
|
||||
subject=gettext_noop("Your recording is ready"),
|
||||
recipients=["franc@test.com"],
|
||||
context={"room_name": "Room", "link": "https://acme.com/r/1"},
|
||||
language="fr-fr",
|
||||
)
|
||||
|
||||
subject, body, *_ = mock_send_mail.call_args[0]
|
||||
assert subject == "Votre enregistrement est prêt"
|
||||
assert "Votre enregistrement est prêt !" in body
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.tasks.mail.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
|
||||
)
|
||||
@mock.patch("core.tasks.mail.render_to_string", return_value="content")
|
||||
def test_send_email_raises_on_smtp_error(mock_render, mock_send_mail, email_settings):
|
||||
"""SMTP errors propagate so Celery can retry the task."""
|
||||
with pytest.raises(smtplib.SMTPException):
|
||||
send_email(
|
||||
template="some_template",
|
||||
subject="Any subject",
|
||||
recipients=["a@test.com"],
|
||||
)
|
||||
|
||||
|
||||
def test_smtp_backend_uses_email_timeout(settings):
|
||||
"""A stalled SMTP server cannot hold the worker forever."""
|
||||
settings.EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
settings.EMAIL_TIMEOUT = 12
|
||||
|
||||
assert get_connection().timeout == 12
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Unit tests for the opt-in psycopg connection pool settings."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import pytest
|
||||
|
||||
from meet import settings
|
||||
|
||||
|
||||
def make_configuration(**overrides):
|
||||
"""Build a throwaway configuration with plain values and its own DATABASES."""
|
||||
attributes = {
|
||||
"DATABASES": {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"OPTIONS": {"sslmode": "require"},
|
||||
}
|
||||
},
|
||||
"DB_PSYCOPG_POOL_ENABLED": True,
|
||||
"DB_PSYCOPG_POOL_MIN_SIZE": 1,
|
||||
"DB_PSYCOPG_POOL_MAX_SIZE": 3,
|
||||
"DB_PSYCOPG_POOL_TIMEOUT": 30.0,
|
||||
"DB_PSYCOPG_POOL_MAX_IDLE": 600.0,
|
||||
**overrides,
|
||||
}
|
||||
return type("PoolConfiguration", (settings.Test,), attributes)
|
||||
|
||||
|
||||
def test_database_pool_disabled_leaves_options_untouched():
|
||||
"""Without the flag, no connection pool is configured."""
|
||||
configuration = make_configuration(DB_PSYCOPG_POOL_ENABLED=False)
|
||||
|
||||
configuration._configure_database_pool()
|
||||
|
||||
assert configuration.DATABASES["default"]["OPTIONS"] == {"sslmode": "require"}
|
||||
assert "CONN_HEALTH_CHECKS" not in configuration.DATABASES["default"]
|
||||
|
||||
|
||||
def test_database_pool_enabled_merges_pool_options():
|
||||
"""The pool and keepalive options are added next to the existing ones."""
|
||||
configuration = make_configuration()
|
||||
|
||||
configuration._configure_database_pool()
|
||||
|
||||
assert configuration.DATABASES["default"]["OPTIONS"] == {
|
||||
"sslmode": "require",
|
||||
"keepalives_idle": 60,
|
||||
"keepalives_interval": 10,
|
||||
"keepalives_count": 5,
|
||||
"pool": {"min_size": 1, "max_size": 3, "timeout": 30.0, "max_idle": 600.0},
|
||||
}
|
||||
|
||||
|
||||
def test_database_pool_enabled_keeps_explicit_keepalives():
|
||||
"""Keepalive values already set, e.g. from DATABASE_URL, are not overridden."""
|
||||
configuration = make_configuration(
|
||||
DATABASES={
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"OPTIONS": {"keepalives_idle": "30", "keepalives": "0"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
configuration._configure_database_pool()
|
||||
|
||||
options = configuration.DATABASES["default"]["OPTIONS"]
|
||||
assert options["keepalives_idle"] == "30"
|
||||
assert options["keepalives"] == "0"
|
||||
assert options["keepalives_interval"] == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("health_checks", [None, False])
|
||||
def test_database_pool_enabled_forces_health_checks(health_checks):
|
||||
"""Pooled connections are checked on checkout, even if DATABASE_URL said no."""
|
||||
database = {"ENGINE": "django.db.backends.postgresql"}
|
||||
if health_checks is not None:
|
||||
database["CONN_HEALTH_CHECKS"] = health_checks
|
||||
configuration = make_configuration(DATABASES={"default": database})
|
||||
|
||||
configuration._configure_database_pool()
|
||||
|
||||
assert configuration.DATABASES["default"]["CONN_HEALTH_CHECKS"] is True
|
||||
|
||||
|
||||
def test_database_pool_enabled_without_options():
|
||||
"""A database without OPTIONS, as built from DATABASE_URL, gets them created."""
|
||||
configuration = make_configuration(
|
||||
DATABASES={"default": {"ENGINE": "django.db.backends.postgresql_psycopg2"}}
|
||||
)
|
||||
|
||||
configuration._configure_database_pool()
|
||||
|
||||
assert configuration.DATABASES["default"]["OPTIONS"]["pool"]["max_size"] == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides,match",
|
||||
[
|
||||
(
|
||||
{"DATABASES": {"default": {"ENGINE": "django.db.backends.sqlite3"}}},
|
||||
"requires the django.db.backends.postgresql engine",
|
||||
),
|
||||
(
|
||||
{
|
||||
"DATABASES": {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"CONN_MAX_AGE": 60,
|
||||
}
|
||||
}
|
||||
},
|
||||
"cannot be combined with persistent connections",
|
||||
),
|
||||
(
|
||||
{"DB_PSYCOPG_POOL_MIN_SIZE": 6},
|
||||
"MIN_SIZE cannot be greater than",
|
||||
),
|
||||
({"DB_PSYCOPG_POOL_TIMEOUT": 0.0}, "must be positive"),
|
||||
({"DB_PSYCOPG_POOL_MAX_IDLE": -1.0}, "must be positive"),
|
||||
],
|
||||
)
|
||||
def test_database_pool_invalid_configuration(overrides, match):
|
||||
"""An invalid pool setup fails at boot."""
|
||||
configuration = make_configuration(**overrides)
|
||||
|
||||
with pytest.raises(ImproperlyConfigured, match=match):
|
||||
configuration._configure_database_pool()
|
||||
@@ -17,7 +17,6 @@ import warnings
|
||||
from os import path
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import dj_database_url
|
||||
@@ -140,7 +139,7 @@ class Base(Configuration):
|
||||
)
|
||||
else {
|
||||
"ENGINE": values.Value(
|
||||
"django.db.backends.postgresql",
|
||||
"django.db.backends.postgresql_psycopg2",
|
||||
environ_name="DB_ENGINE",
|
||||
environ_prefix=None,
|
||||
),
|
||||
@@ -155,21 +154,6 @@ class Base(Configuration):
|
||||
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
|
||||
}
|
||||
}
|
||||
DB_PSYCOPG_POOL_ENABLED = values.BooleanValue(
|
||||
False, environ_name="DB_PSYCOPG_POOL_ENABLED", environ_prefix=None
|
||||
)
|
||||
DB_PSYCOPG_POOL_MIN_SIZE = values.PositiveIntegerValue(
|
||||
1, environ_name="DB_PSYCOPG_POOL_MIN_SIZE", environ_prefix=None
|
||||
)
|
||||
DB_PSYCOPG_POOL_MAX_SIZE = values.PositiveIntegerValue(
|
||||
3, environ_name="DB_PSYCOPG_POOL_MAX_SIZE", environ_prefix=None
|
||||
)
|
||||
DB_PSYCOPG_POOL_TIMEOUT = values.FloatValue(
|
||||
30.0, environ_name="DB_PSYCOPG_POOL_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
DB_PSYCOPG_POOL_MAX_IDLE = values.FloatValue(
|
||||
600.0, environ_name="DB_PSYCOPG_POOL_MAX_IDLE", environ_prefix=None
|
||||
)
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
@@ -524,7 +508,6 @@ class Base(Configuration):
|
||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_TIMEOUT = values.PositiveIntegerValue(30)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
EMAIL_BRAND_NAME = values.Value(None)
|
||||
EMAIL_SUPPORT_EMAIL = values.Value(None)
|
||||
@@ -1462,52 +1445,6 @@ class Base(Configuration):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _configure_database_pool(cls):
|
||||
"""Inject the psycopg pool options into the default database when enabled."""
|
||||
if not cls.DB_PSYCOPG_POOL_ENABLED:
|
||||
return
|
||||
|
||||
database = cls.DATABASES["default"]
|
||||
if database.get("ENGINE") not in (
|
||||
"django.db.backends.postgresql",
|
||||
"django.db.backends.postgresql_psycopg2",
|
||||
):
|
||||
raise ImproperlyConfigured(
|
||||
"DB_PSYCOPG_POOL_ENABLED requires the django.db.backends.postgresql "
|
||||
f"engine, got {database.get('ENGINE')!r}."
|
||||
)
|
||||
if database.get("CONN_MAX_AGE"):
|
||||
raise ImproperlyConfigured(
|
||||
"DB_PSYCOPG_POOL_ENABLED cannot be combined with persistent "
|
||||
"connections; set CONN_MAX_AGE to 0."
|
||||
)
|
||||
if cls.DB_PSYCOPG_POOL_MIN_SIZE > cls.DB_PSYCOPG_POOL_MAX_SIZE:
|
||||
raise ImproperlyConfigured(
|
||||
"DB_PSYCOPG_POOL_MIN_SIZE cannot be greater than "
|
||||
"DB_PSYCOPG_POOL_MAX_SIZE."
|
||||
)
|
||||
if cls.DB_PSYCOPG_POOL_TIMEOUT <= 0 or cls.DB_PSYCOPG_POOL_MAX_IDLE <= 0:
|
||||
raise ImproperlyConfigured(
|
||||
"DB_PSYCOPG_POOL_TIMEOUT and DB_PSYCOPG_POOL_MAX_IDLE must be positive."
|
||||
)
|
||||
|
||||
database["OPTIONS"] = {
|
||||
"keepalives_idle": 60,
|
||||
"keepalives_interval": 10,
|
||||
"keepalives_count": 5,
|
||||
**database.get("OPTIONS", {}),
|
||||
"pool": {
|
||||
"min_size": cls.DB_PSYCOPG_POOL_MIN_SIZE,
|
||||
"max_size": cls.DB_PSYCOPG_POOL_MAX_SIZE,
|
||||
"timeout": cls.DB_PSYCOPG_POOL_TIMEOUT,
|
||||
"max_idle": cls.DB_PSYCOPG_POOL_MAX_IDLE,
|
||||
},
|
||||
}
|
||||
# The pool does not watch idle connections: check each one on checkout
|
||||
# so a broken connection is replaced instead of failing the request.
|
||||
database["CONN_HEALTH_CHECKS"] = True
|
||||
|
||||
@classmethod
|
||||
def post_setup(cls):
|
||||
"""Post setup configuration.
|
||||
@@ -1522,7 +1459,6 @@ class Base(Configuration):
|
||||
)
|
||||
|
||||
cls._check_recording_encoding_maps()
|
||||
cls._configure_database_pool()
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.35.0"
|
||||
version = "1.34.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -51,7 +51,7 @@ dependencies = [
|
||||
"markdown==3.10.3",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==7.44.0",
|
||||
"psycopg[binary,pool]==3.3.4",
|
||||
"psycopg[binary]==3.3.4",
|
||||
"pydantic==2.13.4",
|
||||
"PyJWT==2.14.0",
|
||||
"python-frontmatter==1.3.0",
|
||||
|
||||
Generated
+3
-18
@@ -1297,7 +1297,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.35.0"
|
||||
version = "1.34.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1331,7 +1331,7 @@ dependencies = [
|
||||
{ name = "nested-multipart-parser" },
|
||||
{ name = "phonenumbers" },
|
||||
{ name = "posthog" },
|
||||
{ name = "psycopg", extra = ["binary", "pool"] },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-frontmatter" },
|
||||
@@ -1396,7 +1396,7 @@ requires-dist = [
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "phonenumbers", specifier = "==9.0.37" },
|
||||
{ name = "posthog", specifier = "==7.44.0" },
|
||||
{ name = "psycopg", extras = ["binary", "pool"], specifier = "==3.3.4" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.4" },
|
||||
{ name = "pydantic", specifier = "==2.13.4" },
|
||||
{ name = "pyjwt", specifier = "==2.14.0" },
|
||||
{ name = "python-frontmatter", specifier = "==1.3.0" },
|
||||
@@ -1875,9 +1875,6 @@ wheels = [
|
||||
binary = [
|
||||
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||
]
|
||||
pool = [
|
||||
{ name = "psycopg-pool" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
@@ -1908,18 +1905,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-pool"
|
||||
version = "3.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptyprocess"
|
||||
version = "0.7.0"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -6,5 +6,4 @@ export const keys = {
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
roomCreationCallback: 'roomCreationCallback',
|
||||
files: 'files',
|
||||
startRecording: 'startRecording',
|
||||
}
|
||||
|
||||
@@ -36,13 +36,6 @@ export interface ApiConfig {
|
||||
available_modes?: RecordingMode[]
|
||||
expiration_days?: number
|
||||
max_duration?: number
|
||||
encoding?: {
|
||||
is_custom_enabled: boolean
|
||||
available_resolutions: string[]
|
||||
available_profiles: string[]
|
||||
default_resolution?: string | null
|
||||
default_profile?: string | null
|
||||
}
|
||||
}
|
||||
background_image: {
|
||||
upload_is_enabled: boolean
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.59456 12.8115C8.53273 12.5511 8.5 12.2794 8.5 12C8.5 11.7207 8.53272 11.449 8.59454 11.1886L7.60291 10.6161L8.60291 8.88402L9.59523 9.45694C9.98834 9.08508 10.4676 8.80338 11 8.64494V7.5H13V8.64494C13.5324 8.80337 14.0116 9.08506 14.4047 9.4569L15.3971 8.88393L16.3972 10.616L15.4054 11.1885C15.4673 11.449 15.5 11.7207 15.5 12C15.5 12.2793 15.4673 12.551 15.4055 12.8114L16.3972 13.3839L15.3972 15.116L14.4048 14.543C14.0117 14.9149 13.5325 15.1966 13.0001 15.355V16.5H11.0001V15.3551C10.4677 15.1967 9.98844 14.915 9.59532 14.5431L8.60297 15.1161L7.60291 13.384L8.59456 12.8115ZM12 13.5C12.8284 13.5 13.5 12.8284 13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716 10.5 12C10.5 12.8284 11.1716 13.5 12 13.5ZM15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 994 B |
@@ -177,18 +177,31 @@ export const MainNotificationToast = () => {
|
||||
changedAttributes: Record<string, string>,
|
||||
participant: Participant
|
||||
) => {
|
||||
if (!participant.isLocal || !('room_role' in changedAttributes)) return
|
||||
const newRole = changedAttributes['room_role']
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.RoleChanged,
|
||||
newRole: newRole,
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.ROLE_CHANGED,
|
||||
}
|
||||
)
|
||||
if (!participant.isLocal) return
|
||||
if ('room_role' in changedAttributes) {
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.RoleChanged,
|
||||
newRole: changedAttributes['room_role'],
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.ROLE_CHANGED,
|
||||
}
|
||||
)
|
||||
}
|
||||
if ('is_speaker' in changedAttributes) {
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.SpeakerChanged,
|
||||
isSpeaker: changedAttributes['is_speaker'] === 'true',
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.ROLE_CHANGED,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
|
||||
|
||||
|
||||
@@ -22,4 +22,5 @@ export enum NotificationType {
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
RoleChanged = 'roleChanged',
|
||||
SpeakerChanged = 'speakerChanged',
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
||||
import { ToastSpeakerChanged } from './ToastSpeakerChanged'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -78,6 +79,9 @@ const renderToast = (
|
||||
case NotificationType.RoleChanged:
|
||||
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.SpeakerChanged:
|
||||
return <ToastSpeakerChanged key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
|
||||
export function ToastSpeakerChanged({ state, ...props }: Readonly<ToastProps>) {
|
||||
const { t } = useTranslation('notifications', { keyPrefix: 'speakerChanged' })
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t(props.toast.content.isSpeaker ? 'granted' : 'removed')}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Participant, Track } from 'livekit-client'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useDefaultPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const SPEAKER_ATTRIBUTE = 'is_speaker'
|
||||
|
||||
export const getParticipantIsSpeaker = (participant: Participant): boolean =>
|
||||
participant.attributes?.[SPEAKER_ATTRIBUTE] === 'true'
|
||||
|
||||
export const useParticipantSpeaker = () => {
|
||||
const data = useRoomData()
|
||||
const defaultSources = useDefaultPublishSources()
|
||||
|
||||
const updateSpeaker = async (
|
||||
participant: Participant,
|
||||
isSpeaker: boolean,
|
||||
sources: Track.Source[]
|
||||
) => {
|
||||
if (!data?.id) {
|
||||
throw new Error('Room id is not available')
|
||||
}
|
||||
|
||||
const attributes: Record<string, string> = {
|
||||
[SPEAKER_ATTRIBUTE]: isSpeaker ? 'true' : '',
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchApi(`rooms/${data.id}/update-participant/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_identity: participant.identity,
|
||||
attributes,
|
||||
permission: {
|
||||
can_subscribe: participant.permissions?.canSubscribe,
|
||||
can_publish_data: participant.permissions?.canPublishData,
|
||||
can_update_metadata: participant.permissions?.canUpdateMetadata,
|
||||
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
|
||||
can_publish: sources.length > 0,
|
||||
can_publish_sources: sources,
|
||||
},
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
reportError('permissions_api_failure', error, {
|
||||
context: `Failed to update speaker status of ${participant.identity}`,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const makeSpeaker = (participant: Participant) =>
|
||||
updateSpeaker(participant, true, defaultSources)
|
||||
|
||||
const removeSpeaker = (
|
||||
participant: Participant,
|
||||
roomSources: Track.Source[]
|
||||
) => updateSpeaker(participant, false, roomSources)
|
||||
|
||||
return { makeSpeaker, removeSpeaker }
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { type LocalParticipant, type Participant, Track } from 'livekit-client'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import {
|
||||
useIsSpeaking,
|
||||
useParticipantAttribute,
|
||||
useTrackMutedIndicator,
|
||||
} from '@livekit/components-react'
|
||||
import Source = Track.Source
|
||||
@@ -86,6 +87,16 @@ type ParticipantListItemProps = {
|
||||
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const name = participant.name || participant.identity
|
||||
const isSpeaker =
|
||||
useParticipantAttribute('is_speaker', { participant }) === 'true'
|
||||
|
||||
const roleLabels = [
|
||||
getParticipantIsRoomOwner(participant) && t('participants.host'),
|
||||
getParticipantIsRoomAdmin(participant) && t('participants.cohost'),
|
||||
getParticipantIsRoomMember(participant) && t('participants.member'),
|
||||
isSpeaker && t('participants.speaker'),
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
<HStack
|
||||
role="listitem"
|
||||
@@ -107,12 +118,7 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||
displayedName={name}
|
||||
isLocal={isLocal(participant)}
|
||||
/>
|
||||
<Text variant="xsNote">
|
||||
{getParticipantIsRoomOwner(participant) && t('participants.host')}
|
||||
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
|
||||
{getParticipantIsRoomMember(participant) &&
|
||||
t('participants.member')}
|
||||
</Text>
|
||||
<Text variant="xsNote">{roleLabels.join(' · ')}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack flexShrink={0}>
|
||||
|
||||
@@ -3,8 +3,10 @@ import type { Participant } from 'livekit-client'
|
||||
import { PinMenuItem } from './items/PinMenuItem'
|
||||
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
||||
import { PromoteMenuItem } from './items/PromoteMenuItem'
|
||||
import { SpeakerMenuItem } from './items/SpeakerMenuItem'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useParticipantAttributes } from '@livekit/components-react'
|
||||
import { useHasLockedSource } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
|
||||
|
||||
export const ParticipantMenu = ({
|
||||
participant,
|
||||
@@ -18,8 +20,13 @@ export const ParticipantMenu = ({
|
||||
const isAdmin = attributes?.room_role === 'administrator'
|
||||
const isOwner = attributes?.room_role === 'owner'
|
||||
const isAuthenticated = attributes?.is_authenticated == 'true'
|
||||
const isSpeaker = attributes?.is_speaker === 'true'
|
||||
|
||||
const hasLockedSource = useHasLockedSource()
|
||||
|
||||
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
|
||||
const canToggleSpeaker =
|
||||
canManage && !isAdmin && (isSpeaker || hasLockedSource)
|
||||
|
||||
return (
|
||||
<RACMenu
|
||||
@@ -41,6 +48,9 @@ export const ParticipantMenu = ({
|
||||
displayedName={participant.name}
|
||||
/>
|
||||
)}
|
||||
{canToggleSpeaker && (
|
||||
<SpeakerMenuItem participant={participant} isSpeaker={isSpeaker} />
|
||||
)}
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { RiMicOffLine, RiUserVoiceLine } from '@remixicon/react'
|
||||
import { useParticipantSpeaker } from '@/features/participants/api/updateParticipantSpeaker'
|
||||
import { useRoomPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
type SpeakerMenuItemProps = {
|
||||
participant: Participant
|
||||
isSpeaker: boolean
|
||||
}
|
||||
|
||||
export const SpeakerMenuItem = React.memo(
|
||||
({ participant, isSpeaker }: SpeakerMenuItemProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
||||
const { makeSpeaker, removeSpeaker } = useParticipantSpeaker()
|
||||
const roomSources = useRoomPublishSources()
|
||||
|
||||
const label = isSpeaker ? 'removeSpeaker' : 'makeSpeaker'
|
||||
const Icon = isSpeaker ? RiMicOffLine : RiUserVoiceLine
|
||||
|
||||
const toggleSpeaker = useCallback(
|
||||
() =>
|
||||
isSpeaker
|
||||
? removeSpeaker(participant, roomSources)
|
||||
: makeSpeaker(participant),
|
||||
[isSpeaker, participant, roomSources, makeSpeaker, removeSpeaker]
|
||||
)
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
aria-label={t(`${label}.ariaLabel`, {
|
||||
name: participant.name || participant.identity,
|
||||
})}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
onAction={toggleSpeaker}
|
||||
>
|
||||
<HStack gap={0.25} minWidth={280}>
|
||||
<Icon size={20} aria-hidden />
|
||||
{t(`${label}.label`)}
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SpeakerMenuItem.displayName = 'SpeakerMenuItem'
|
||||
@@ -2,22 +2,12 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import type { ApiError } from '@/api/ApiError'
|
||||
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { RecordingMode } from '../types'
|
||||
import type { RecordingEncoding } from '../hooks/useRecordingEncoding'
|
||||
|
||||
export interface StartRecordingOptions {
|
||||
language?: string
|
||||
transcribe?: boolean
|
||||
collect_metadata?: boolean
|
||||
original_mode?: RecordingMode
|
||||
encoding?: RecordingEncoding
|
||||
}
|
||||
|
||||
export interface StartRecordingParams {
|
||||
id: string
|
||||
mode?: RecordingMode
|
||||
options?: StartRecordingOptions
|
||||
options?: Record<string, string | boolean>
|
||||
}
|
||||
|
||||
const startRecording = ({
|
||||
@@ -38,7 +28,6 @@ export function useStartRecording(
|
||||
options?: UseMutationOptions<ApiRoom, ApiError, StartRecordingParams>
|
||||
) {
|
||||
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
|
||||
mutationKey: [keys.startRecording],
|
||||
mutationFn: startRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Div, H, Text } from '@/primitives'
|
||||
import { Div, H, Text } from '@/primitives'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
@@ -22,13 +22,8 @@ import { ControlsButton } from './ControlsButton'
|
||||
import { RowWrapper } from './RowWrapper'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Checkbox } from '@/primitives/Checkbox'
|
||||
import {
|
||||
SettingsDialogExtendedKey,
|
||||
useTranscriptionLanguage,
|
||||
} from '@/features/settings'
|
||||
import { useTranscriptionLanguage } from '@/features/settings'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useRecordingEncoding } from '../hooks/useRecordingEncoding'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
@@ -58,8 +53,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
const { selectedLanguageKey, isLanguageSetToAuto } =
|
||||
useTranscriptionLanguage()
|
||||
|
||||
const { encoding, isCustomEncodingEnabled } = useRecordingEncoding()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
|
||||
@@ -100,7 +93,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
language: selectedLanguageKey,
|
||||
}),
|
||||
...(includeTranscript && hasTranscriptAccess && { transcribe: true }),
|
||||
...(encoding && { encoding }),
|
||||
}
|
||||
|
||||
await startRecording({
|
||||
@@ -115,8 +107,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
captureEvent('screen-recording-started', {
|
||||
includeTranscript: includeTranscript,
|
||||
language: selectedLanguageKey,
|
||||
resolution: encoding?.resolution,
|
||||
profile: encoding?.profile,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -193,41 +183,9 @@ export const ScreenRecordingSidePanel = () => {
|
||||
<RowWrapper iconName="cloud_download" position="first">
|
||||
<Text variant="sm">{t('details.destination')}</Text>
|
||||
</RowWrapper>
|
||||
<RowWrapper
|
||||
iconName="mail"
|
||||
position={isCustomEncodingEnabled ? 'middle' : 'last'}
|
||||
>
|
||||
<RowWrapper iconName="mail" position="last">
|
||||
<Text variant="sm">{t('details.receiver')}</Text>
|
||||
</RowWrapper>
|
||||
{isCustomEncodingEnabled && (
|
||||
<RowWrapper iconName="file_settings_line" position="last">
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
minWidth: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
})}
|
||||
>
|
||||
{t('details.quality')}
|
||||
</Text>
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({ flexShrink: 0, whiteSpace: 'nowrap' })}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
size="xs"
|
||||
onPress={() =>
|
||||
openSettingsDialog(SettingsDialogExtendedKey.RECORDING)
|
||||
}
|
||||
>
|
||||
{t('details.qualityEdit')}
|
||||
</Button>
|
||||
</Text>
|
||||
</RowWrapper>
|
||||
)}
|
||||
{hasTranscriptAccess && (
|
||||
<>
|
||||
<div className={css({ height: '15px' })} />
|
||||
|
||||
@@ -29,7 +29,6 @@ import { NoAccessView } from './NoAccessView'
|
||||
import { ControlsButton } from './ControlsButton'
|
||||
import { RowWrapper } from './RowWrapper'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useRecordingEncoding } from '../hooks/useRecordingEncoding'
|
||||
import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnabled'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
@@ -67,8 +66,6 @@ export const TranscriptSidePanel = () => {
|
||||
|
||||
const isMetadataCollectorEnabled = useIsMetadataCollectorEnabled()
|
||||
|
||||
const { encoding } = useRecordingEncoding()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
|
||||
@@ -117,7 +114,6 @@ export const TranscriptSidePanel = () => {
|
||||
...(withScreenRecording && {
|
||||
transcribe: true,
|
||||
original_mode: RecordingMode.Transcript,
|
||||
...(encoding && { encoding }),
|
||||
}),
|
||||
collect_metadata: isMetadataCollectorEnabled,
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useIsMutating } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { RecordingMode } from '../types'
|
||||
import { useRecordingStatuses } from './useRecordingStatuses'
|
||||
|
||||
/**
|
||||
* True while a recording is being requested, starting, started or saving.
|
||||
* Recording settings must not change during that window, as they were (or
|
||||
* are about to be) sent to the backend for the current recording.
|
||||
*/
|
||||
export const useIsRecordingLocked = () => {
|
||||
const screenRecording = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
const transcript = useRecordingStatuses(RecordingMode.Transcript)
|
||||
const pendingStarts = useIsMutating({ mutationKey: [keys.startRecording] })
|
||||
|
||||
return pendingStarts > 0 || screenRecording.isActive || transcript.isActive
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import {
|
||||
recordingEncodingStore,
|
||||
setRecordingEncodingPreference,
|
||||
} from '@/stores/recordingEncoding'
|
||||
|
||||
export type RecordingEncoding = {
|
||||
resolution?: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
export const DEFAULT_ENCODING_OPTION = '__default__'
|
||||
|
||||
/**
|
||||
* Pick the stored value if the backend still offers it, otherwise the
|
||||
* backend default, otherwise the "Default" option.
|
||||
*/
|
||||
const resolveValue = (
|
||||
available: string[],
|
||||
stored?: string,
|
||||
fallback?: string | null
|
||||
): string => {
|
||||
if (stored && available.includes(stored)) return stored
|
||||
if (fallback && available.includes(fallback)) return fallback
|
||||
return DEFAULT_ENCODING_OPTION
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording encoding preferences (resolution + profile).
|
||||
*
|
||||
* Values are persisted in localStorage, per browser, and always
|
||||
* reconciled against what the backend currently exposes, so a stale value
|
||||
* (e.g. a profile removed by the operator) never reaches the API.
|
||||
*/
|
||||
export const useRecordingEncoding = () => {
|
||||
const { data } = useConfig()
|
||||
const snap = useSnapshot(recordingEncodingStore)
|
||||
|
||||
const encodingConfig = data?.recording?.encoding
|
||||
const availableResolutions = useMemo(
|
||||
() => encodingConfig?.available_resolutions ?? [],
|
||||
[encodingConfig]
|
||||
)
|
||||
const availableProfiles = useMemo(
|
||||
() => encodingConfig?.available_profiles ?? [],
|
||||
[encodingConfig]
|
||||
)
|
||||
const hasDefaultResolution = availableResolutions.includes(
|
||||
encodingConfig?.default_resolution ?? ''
|
||||
)
|
||||
const hasDefaultProfile = availableProfiles.includes(
|
||||
encodingConfig?.default_profile ?? ''
|
||||
)
|
||||
|
||||
const isCustomEncodingEnabled = !!encodingConfig?.is_custom_enabled
|
||||
|
||||
const resolution = resolveValue(
|
||||
availableResolutions,
|
||||
snap.resolution,
|
||||
encodingConfig?.default_resolution
|
||||
)
|
||||
const profile = resolveValue(
|
||||
availableProfiles,
|
||||
snap.profile,
|
||||
encodingConfig?.default_profile
|
||||
)
|
||||
|
||||
const setResolution = useCallback(
|
||||
(value: string) => {
|
||||
if (
|
||||
value !== DEFAULT_ENCODING_OPTION &&
|
||||
!availableResolutions.includes(value)
|
||||
)
|
||||
return
|
||||
setRecordingEncodingPreference({ resolution: value })
|
||||
},
|
||||
[availableResolutions]
|
||||
)
|
||||
|
||||
const setProfile = useCallback(
|
||||
(value: string) => {
|
||||
if (
|
||||
value !== DEFAULT_ENCODING_OPTION &&
|
||||
!availableProfiles.includes(value)
|
||||
)
|
||||
return
|
||||
setRecordingEncodingPreference({ profile: value })
|
||||
},
|
||||
[availableProfiles]
|
||||
)
|
||||
|
||||
// Payload for the start-recording API. Undefined when per-recording
|
||||
// encoding is disabled, as the backend would reject it.
|
||||
const encoding: RecordingEncoding | undefined = useMemo(() => {
|
||||
// The API requires a resolution whenever the encoding is modified: a
|
||||
// profile cannot be sent alone. So if the resolution is "Default", send
|
||||
// no payload at all and let the backend apply its default encoding.
|
||||
if (!isCustomEncodingEnabled || resolution === DEFAULT_ENCODING_OPTION)
|
||||
return undefined
|
||||
return {
|
||||
resolution,
|
||||
// Omitted when "Default": the backend applies its default profile.
|
||||
...(profile !== DEFAULT_ENCODING_OPTION && { profile }),
|
||||
}
|
||||
}, [isCustomEncodingEnabled, resolution, profile])
|
||||
|
||||
return {
|
||||
isCustomEncodingEnabled,
|
||||
availableResolutions,
|
||||
availableProfiles,
|
||||
hasDefaultResolution,
|
||||
hasDefaultProfile,
|
||||
resolution,
|
||||
profile,
|
||||
setResolution,
|
||||
setProfile,
|
||||
encoding,
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,6 @@ export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
||||
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
||||
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
|
||||
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
|
||||
export {
|
||||
useRecordingEncoding,
|
||||
DEFAULT_ENCODING_OPTION,
|
||||
} from './hooks/useRecordingEncoding'
|
||||
export type { RecordingEncoding } from './hooks/useRecordingEncoding'
|
||||
export { useIsRecordingLocked } from './hooks/useIsRecordingLocked'
|
||||
|
||||
// api
|
||||
export { useStartRecording } from './api/startRecording'
|
||||
|
||||
@@ -45,7 +45,6 @@ import { userStore } from '@/stores/user'
|
||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||
import { MeetDevtools } from '@/features/devtools'
|
||||
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
||||
import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioOutputDeviceId'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -121,7 +120,7 @@ export const Conference = ({
|
||||
...VOICE_AUDIO_CONSTRAINTS,
|
||||
},
|
||||
audioOutput: {
|
||||
deviceId: resolveAudioOutputDeviceId(userConfig.audioOutputDeviceId),
|
||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||
},
|
||||
}
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
|
||||
+5
-8
@@ -4,7 +4,6 @@ import { RiVolumeUpLine } from '@remixicon/react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||
import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioOutputDeviceId'
|
||||
|
||||
// Speaker test in the audiooutput menu footer (Meet-style UX). Outputs have
|
||||
// no track: the test plays a bundled file through the selected sink, and
|
||||
@@ -67,8 +66,6 @@ export const OutputSoundTester = ({
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
const outputId = resolveAudioOutputDeviceId(sinkId)
|
||||
|
||||
const latestSinkIdRef = useRef(sinkId)
|
||||
latestSinkIdRef.current = sinkId
|
||||
|
||||
@@ -82,15 +79,15 @@ export const OutputSoundTester = ({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!outputId || !canTestAudioOutput()) return
|
||||
audioRef.current?.setSinkId(outputId).catch(() => {
|
||||
if (!sinkId || !canTestAudioOutput()) return
|
||||
audioRef.current?.setSinkId(sinkId).catch(() => {
|
||||
// Re-routing failed (stale or unplugged device): stop the test rather
|
||||
// than keep playing through the previous sink.
|
||||
if (latestSinkIdRef.current === sinkId) {
|
||||
stopPlayback()
|
||||
}
|
||||
})
|
||||
}, [outputId, sinkId, stopPlayback])
|
||||
}, [sinkId, stopPlayback])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
@@ -111,8 +108,8 @@ export const OutputSoundTester = ({
|
||||
// Confirm routing before starting: a no-op when already routed,
|
||||
// but rejects on a stale device id, so the test never plays
|
||||
// through the wrong sink.
|
||||
if (outputId && canTestAudioOutput()) {
|
||||
await audio.setSinkId(outputId)
|
||||
if (sinkId && canTestAudioOutput()) {
|
||||
await audio.setSinkId(sinkId)
|
||||
}
|
||||
await audio.play()
|
||||
setIsPlaying(true)
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useCallback } from 'react'
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { useRemoteParticipants } from '@livekit/components-react'
|
||||
import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
||||
import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
|
||||
import {
|
||||
getParticipantIsSpeaker,
|
||||
useParticipantSpeaker,
|
||||
} from '@/features/participants/api/updateParticipantSpeaker'
|
||||
import {
|
||||
useDefaultPublishSources,
|
||||
useRoomPublishSources,
|
||||
} from '@/features/rooms/livekit/hooks/useRoomPublishSources'
|
||||
import Source = Track.Source
|
||||
import {
|
||||
NotificationType,
|
||||
@@ -33,8 +40,9 @@ export const usePublishSourcesManager = () => {
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
|
||||
const data = useRoomData()
|
||||
const { data: configData } = useConfig()
|
||||
const configuration = data?.configuration
|
||||
const currentSources = useRoomPublishSources()
|
||||
const defaultSources = useDefaultPublishSources()
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
@@ -42,25 +50,18 @@ export const usePublishSourcesManager = () => {
|
||||
const roomId = data?.slug
|
||||
|
||||
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
|
||||
const { removeSpeaker } = useParticipantSpeaker()
|
||||
const remoteParticipants = useRemoteParticipants({
|
||||
updateOnlyOn: [RoomEvent.ParticipantAttributesChanged],
|
||||
})
|
||||
|
||||
const unprivilegedRemoteParticipants = remoteParticipants.filter(
|
||||
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
|
||||
const restrictedRemoteParticipants = remoteParticipants.filter(
|
||||
(participant) =>
|
||||
!getParticipantIsRoomAdminOrOwner(participant) &&
|
||||
!getParticipantIsSpeaker(participant)
|
||||
)
|
||||
|
||||
const currentSources = useMemo(() => {
|
||||
const defaultSources = configData?.livekit?.default_sources ?? []
|
||||
|
||||
if (
|
||||
configuration?.can_publish_sources == undefined ||
|
||||
!Array.isArray(configuration?.can_publish_sources)
|
||||
) {
|
||||
return defaultSources
|
||||
}
|
||||
return configuration.can_publish_sources
|
||||
}, [configData, configuration?.can_publish_sources])
|
||||
const speakers = remoteParticipants.filter(getParticipantIsSpeaker)
|
||||
|
||||
const updateSource = useCallback(
|
||||
async (sources: Source[], enabled: boolean) => {
|
||||
@@ -84,10 +85,16 @@ export const usePublishSourcesManager = () => {
|
||||
})
|
||||
|
||||
await updateParticipantsPermissions(
|
||||
unprivilegedRemoteParticipants,
|
||||
restrictedRemoteParticipants,
|
||||
newSources
|
||||
)
|
||||
|
||||
if (isSubsetOf(defaultSources, newSources)) {
|
||||
await Promise.all(
|
||||
speakers.map((speaker) => removeSpeaker(speaker, newSources))
|
||||
)
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
/*
|
||||
* We can't rely solely on the ParticipantPermissionsChanged event here,
|
||||
@@ -96,7 +103,7 @@ export const usePublishSourcesManager = () => {
|
||||
* */
|
||||
await notifyParticipants({
|
||||
type: NotificationType.PermissionsRemoved,
|
||||
destinationIdentities: unprivilegedRemoteParticipants.map(
|
||||
destinationIdentities: restrictedRemoteParticipants.map(
|
||||
(p) => p.identity
|
||||
),
|
||||
additionalData: {
|
||||
@@ -119,9 +126,12 @@ export const usePublishSourcesManager = () => {
|
||||
notifyParticipants,
|
||||
configuration,
|
||||
currentSources,
|
||||
defaultSources,
|
||||
roomId,
|
||||
patchRoom,
|
||||
unprivilegedRemoteParticipants,
|
||||
restrictedRemoteParticipants,
|
||||
speakers,
|
||||
removeSpeaker,
|
||||
updateParticipantsPermissions,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Track } from 'livekit-client'
|
||||
import { useMemo } from 'react'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
||||
import Source = Track.Source
|
||||
|
||||
const EMPTY_SOURCES: Source[] = []
|
||||
|
||||
export const useDefaultPublishSources = (): Source[] => {
|
||||
const { data: configData } = useConfig()
|
||||
return configData?.livekit?.default_sources ?? EMPTY_SOURCES
|
||||
}
|
||||
|
||||
export const useRoomPublishSources = (): Source[] => {
|
||||
const data = useRoomData()
|
||||
const defaultSources = useDefaultPublishSources()
|
||||
const roomSources = data?.configuration?.can_publish_sources
|
||||
|
||||
return useMemo(
|
||||
() => (Array.isArray(roomSources) ? roomSources : defaultSources),
|
||||
[defaultSources, roomSources]
|
||||
)
|
||||
}
|
||||
|
||||
export const useHasLockedSource = (): boolean => {
|
||||
const defaultSources = useDefaultPublishSources()
|
||||
const roomSources = useRoomPublishSources()
|
||||
return !isSubsetOf(defaultSources, roomSources)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* `'default'` means "use the system output". Firefox has no device with that
|
||||
* id, so setSinkId('default') rejects. Omit it and the browser keeps the OS
|
||||
* default, which is the same result.
|
||||
*/
|
||||
export const resolveAudioOutputDeviceId = (
|
||||
deviceId: string | undefined
|
||||
): string | undefined => {
|
||||
if (!deviceId || deviceId === 'default') return undefined
|
||||
return deviceId
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
RiVideoOnLine,
|
||||
RiEyeLine,
|
||||
RiKeyboardBoxLine,
|
||||
RiRecordCircleLine,
|
||||
} from '@remixicon/react'
|
||||
import { AccountTab } from './tabs/AccountTab'
|
||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
@@ -27,12 +26,6 @@ import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { AccessibilityTab } from './tabs/AccessibilityTab'
|
||||
import { RecordingTab } from './tabs/RecordingTab'
|
||||
import {
|
||||
RecordingMode,
|
||||
useIsRecordingModeEnabled,
|
||||
useRecordingEncoding,
|
||||
} from '@/features/recording'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -74,12 +67,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const isScreenRecordingEnabled = useIsRecordingModeEnabled(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
const { isCustomEncodingEnabled } = useRecordingEncoding()
|
||||
const showRecordingTab =
|
||||
isAdminOrOwner && !!isScreenRecordingEnabled && isCustomEncodingEnabled
|
||||
|
||||
return (
|
||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||
@@ -134,13 +121,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
|
||||
</Tab>
|
||||
)}
|
||||
{showRecordingTab && (
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.RECORDING}>
|
||||
<RiRecordCircleLine />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)}
|
||||
</Tab>
|
||||
)}
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
|
||||
<RiEyeLine />
|
||||
{isWideScreen &&
|
||||
@@ -160,8 +140,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
|
||||
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
||||
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
||||
{/* Recording tab won't be accessible if the tab is not active in the tab list */}
|
||||
<RecordingTab id={SettingsDialogExtendedKey.RECORDING} />
|
||||
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
|
||||
import { Field, H, Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
DEFAULT_ENCODING_OPTION,
|
||||
useIsRecordingLocked,
|
||||
useRecordingEncoding,
|
||||
} from '@/features/recording'
|
||||
|
||||
export type RecordingTabProps = Pick<TabPanelProps, 'id'>
|
||||
|
||||
export const RecordingTab = ({ id }: RecordingTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'recording' })
|
||||
|
||||
const {
|
||||
availableResolutions,
|
||||
availableProfiles,
|
||||
hasDefaultResolution,
|
||||
hasDefaultProfile,
|
||||
resolution,
|
||||
profile,
|
||||
setResolution,
|
||||
setProfile,
|
||||
} = useRecordingEncoding()
|
||||
|
||||
const isLocked = useIsRecordingLocked()
|
||||
|
||||
// The API requires a resolution to modify the encoding: with a "Default"
|
||||
// resolution no payload is sent, so the profile cannot apply. Show it as
|
||||
// "Default" and disabled until a resolution is chosen.
|
||||
const isResolutionDefault = resolution === DEFAULT_ENCODING_OPTION
|
||||
const selectedProfile = isResolutionDefault ? '' : profile
|
||||
|
||||
const defaultItem = {
|
||||
value: DEFAULT_ENCODING_OPTION,
|
||||
label: t('defaultOption'),
|
||||
}
|
||||
|
||||
// Resolutions and profiles are operator-defined: fall back to the raw key
|
||||
// when no translation exists for a custom value.
|
||||
const resolutionItems = useMemo(
|
||||
() => [
|
||||
...(hasDefaultResolution ? [] : [defaultItem]),
|
||||
...availableResolutions.map((key) => ({
|
||||
value: key,
|
||||
label: t(`resolution.options.${key}`, { defaultValue: key }),
|
||||
})),
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[hasDefaultResolution, availableResolutions, t]
|
||||
)
|
||||
|
||||
const profileItems = useMemo(
|
||||
() => [
|
||||
...(hasDefaultProfile ? [] : [defaultItem]),
|
||||
...availableProfiles.map((key) => ({
|
||||
value: key,
|
||||
label: t(`profile.options.${key}.label`, { defaultValue: key }),
|
||||
})),
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[hasDefaultProfile, availableProfiles, t]
|
||||
)
|
||||
|
||||
const profileDescription = selectedProfile
|
||||
? t(`profile.options.${selectedProfile}.description`, { defaultValue: '' })
|
||||
: ''
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('heading')}</H>
|
||||
{isLocked && (
|
||||
<Text variant="note" role="status" margin="sm">
|
||||
{t('locked')}
|
||||
</Text>
|
||||
)}
|
||||
<Field
|
||||
type="select"
|
||||
label={t('resolution.label')}
|
||||
description={t('resolution.description')}
|
||||
items={resolutionItems}
|
||||
selectedKey={resolution ?? null}
|
||||
isDisabled={isLocked}
|
||||
onSelectionChange={(key) => {
|
||||
if (isLocked || key === null) return
|
||||
setResolution(String(key))
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('profile.label')}
|
||||
description={profileDescription || undefined}
|
||||
items={profileItems}
|
||||
selectedKey={selectedProfile ?? null}
|
||||
isDisabled={isLocked || isResolutionDefault}
|
||||
onSelectionChange={(key) => {
|
||||
if (isLocked || isResolutionDefault || key === null) return
|
||||
setProfile(String(key))
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
@@ -7,5 +7,4 @@ export enum SettingsDialogExtendedKey {
|
||||
TRANSCRIPTION = 'transcription',
|
||||
SHORTCUTS = 'shortcuts',
|
||||
ACCESSIBILITY = 'accessibility',
|
||||
RECORDING = 'recording',
|
||||
}
|
||||
|
||||
@@ -68,5 +68,9 @@
|
||||
"member": "Mitglied"
|
||||
}
|
||||
},
|
||||
"speakerChanged": {
|
||||
"granted": "Du bist jetzt Sprecher. Du kannst dein Mikrofon, deine Kamera und die Bildschirmfreigabe nutzen.",
|
||||
"removed": "Du bist nicht mehr Sprecher."
|
||||
},
|
||||
"openMenu": "Menü öffnen"
|
||||
}
|
||||
|
||||
@@ -535,9 +535,7 @@
|
||||
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
|
||||
"loading": "Aufzeichnung wird gestartet",
|
||||
"linkMore": "Mehr erfahren",
|
||||
"transcription": "Diese Aufzeichnung transkribieren",
|
||||
"quality": "Videoqualität anpassbar.",
|
||||
"qualityEdit": "Ändern"
|
||||
"transcription": "Diese Aufzeichnung transkribieren"
|
||||
},
|
||||
"button": {
|
||||
"start": "Meeting-Aufzeichnung starten",
|
||||
@@ -661,6 +659,7 @@
|
||||
"host": "Host",
|
||||
"cohost": "Co-host",
|
||||
"member": "Mitglied",
|
||||
"speaker": "Sprecher",
|
||||
"contributors": "Teilnehmende",
|
||||
"collapsable": {
|
||||
"open": "{{name}}-Liste öffnen",
|
||||
@@ -716,6 +715,14 @@
|
||||
"demote": {
|
||||
"label": "Co-Host-Rolle entfernen",
|
||||
"ariaLabel": "Die Co-Host-Rolle von {{name}} entfernen"
|
||||
},
|
||||
"makeSpeaker": {
|
||||
"label": "Zum Sprecher machen",
|
||||
"ariaLabel": "{{name}} zum Sprecher machen"
|
||||
},
|
||||
"removeSpeaker": {
|
||||
"label": "Sprecherrolle entfernen",
|
||||
"ariaLabel": "Sprecherrolle von {{name}} entfernen"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
|
||||
@@ -184,46 +184,10 @@
|
||||
"accessibility": "Barrierefreiheit",
|
||||
"transcription": "Transkription",
|
||||
"shortcuts": "Tastenkürzel",
|
||||
"rooms": "Räume",
|
||||
"recording": "Aufzeichnung"
|
||||
"rooms": "Räume"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Standardeinstellungen für Räume",
|
||||
"description": "Wählen Sie die Einstellungen, die standardmäßig auf neue von Ihnen erstellte Räume angewendet werden. Sie können sie für jedes Meeting weiterhin in den Moderationseinstellungen ändern."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Aufzeichnungsqualität",
|
||||
"locked": "Diese Einstellungen können nicht geändert werden, während eine Aufzeichnung startet oder läuft.",
|
||||
"profile": {
|
||||
"label": "Inhaltstyp",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Sprechende Personen",
|
||||
"description": "Optimiert für Personen vor der Kamera mit gelegentlichen Folien. Kleinste Dateien."
|
||||
},
|
||||
"text": {
|
||||
"label": "Präsentationen und Text",
|
||||
"description": "Hält Text beim Teilen von Folien, Dokumenten oder Code scharf."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Gemischte Inhalte",
|
||||
"description": "Ausgewogen für Kameras, Bildschirmfreigabe und moderate Bewegung."
|
||||
},
|
||||
"full": {
|
||||
"label": "Maximale Qualität",
|
||||
"description": "Höchste Wiedergabetreue und flüssigste Bewegung. Größte Dateien."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Auflösung",
|
||||
"description": "Höhere Auflösungen erzeugen größere Dateien.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
},
|
||||
"defaultOption": "Standard"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,5 +68,9 @@
|
||||
"member": "Member"
|
||||
}
|
||||
},
|
||||
"speakerChanged": {
|
||||
"granted": "You are now a speaker. You can use your microphone, camera and screen sharing.",
|
||||
"removed": "You are no longer a speaker."
|
||||
},
|
||||
"openMenu": "Open menu"
|
||||
}
|
||||
|
||||
@@ -535,9 +535,7 @@
|
||||
"destination": "This recording will be temporarily stored on our servers",
|
||||
"loading": "Starting recording",
|
||||
"linkMore": "Learn more",
|
||||
"transcription": "Transcribe this recording",
|
||||
"quality": "Adjustable video quality.",
|
||||
"qualityEdit": "Change"
|
||||
"transcription": "Transcribe this recording"
|
||||
},
|
||||
"button": {
|
||||
"start": "Start recording the meeting",
|
||||
@@ -661,6 +659,7 @@
|
||||
"host": "Host",
|
||||
"cohost": "Co-host",
|
||||
"member": "Member",
|
||||
"speaker": "Speaker",
|
||||
"contributors": "Contributors",
|
||||
"collapsable": {
|
||||
"open": "Open {{name}} list",
|
||||
@@ -716,6 +715,14 @@
|
||||
"demote": {
|
||||
"label": "Remove co-host role",
|
||||
"ariaLabel": "Remove {{name}}'s co-host role"
|
||||
},
|
||||
"makeSpeaker": {
|
||||
"label": "Make speaker",
|
||||
"ariaLabel": "Make {{name}} a speaker"
|
||||
},
|
||||
"removeSpeaker": {
|
||||
"label": "Remove speaker",
|
||||
"ariaLabel": "Remove {{name}} as speaker"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
|
||||
@@ -184,46 +184,10 @@
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription",
|
||||
"shortcuts": "Shortcuts",
|
||||
"rooms": "Rooms",
|
||||
"recording": "Recording"
|
||||
"rooms": "Rooms"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Default room settings",
|
||||
"description": "Choose the settings applied by default to the new rooms you create. You can still change them for each meeting from the host settings."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Recording quality",
|
||||
"locked": "These settings can't be changed while a recording is starting or in progress.",
|
||||
"profile": {
|
||||
"label": "Content type",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Talking heads",
|
||||
"description": "Optimized for people speaking on camera with occasional slides. Smallest files."
|
||||
},
|
||||
"text": {
|
||||
"label": "Presentations and text",
|
||||
"description": "Keeps text sharp when sharing slides, documents or code."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Mixed content",
|
||||
"description": "Balanced for a mix of cameras, screen sharing and moderate motion."
|
||||
},
|
||||
"full": {
|
||||
"label": "Maximum quality",
|
||||
"description": "Highest fidelity and smoothest motion. Largest files."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Resolution",
|
||||
"description": "Higher resolutions produce larger files.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
},
|
||||
"defaultOption": "Default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,5 +68,9 @@
|
||||
"member": "Miembro"
|
||||
}
|
||||
},
|
||||
"speakerChanged": {
|
||||
"granted": "Ahora eres ponente. Puedes usar tu micrófono, tu cámara y compartir pantalla.",
|
||||
"removed": "Ya no eres ponente."
|
||||
},
|
||||
"openMenu": "Abrir el menú"
|
||||
}
|
||||
|
||||
@@ -534,9 +534,7 @@
|
||||
"destination": "Esta grabación se conservará temporalmente en nuestros servidores",
|
||||
"loading": "Iniciando la grabación",
|
||||
"linkMore": "Más información",
|
||||
"transcription": "Transcribir esta grabación",
|
||||
"quality": "Calidad de vídeo ajustable.",
|
||||
"qualityEdit": "Cambiar"
|
||||
"transcription": "Transcribir esta grabación"
|
||||
},
|
||||
"button": {
|
||||
"start": "Empezar a grabar la reunión",
|
||||
@@ -661,6 +659,7 @@
|
||||
"host": "Organizador de la reunión",
|
||||
"cohost": "Coorganizador",
|
||||
"member": "Miembro",
|
||||
"speaker": "Ponente",
|
||||
"collapsable": {
|
||||
"open": "Abrir la lista {{name}}",
|
||||
"close": "Cerrar la lista {{name}}"
|
||||
@@ -715,6 +714,14 @@
|
||||
"demote": {
|
||||
"label": "Retirar el rol de coorganizador",
|
||||
"ariaLabel": "Retirar el rol de coorganizador a {{name}}"
|
||||
},
|
||||
"makeSpeaker": {
|
||||
"label": "Nombrar ponente",
|
||||
"ariaLabel": "Nombrar ponente a {{name}}"
|
||||
},
|
||||
"removeSpeaker": {
|
||||
"label": "Quitar rol de ponente",
|
||||
"ariaLabel": "Quitar el rol de ponente a {{name}}"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
|
||||
@@ -184,46 +184,10 @@
|
||||
"accessibility": "Accesibilidad",
|
||||
"transcription": "Transcripción",
|
||||
"shortcuts": "Atajos",
|
||||
"rooms": "Reuniones",
|
||||
"recording": "Grabación"
|
||||
"rooms": "Reuniones"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Ajustes predeterminados de las reuniones",
|
||||
"description": "Elige la configuración que se aplica de forma predeterminada a las nuevas reuniones que crees. Siempre podrás modificarla para cada reunión desde el menu de configuración."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Calidad de la grabación",
|
||||
"locked": "Estos ajustes no se pueden modificar mientras una grabación se está iniciando o está en curso.",
|
||||
"profile": {
|
||||
"label": "Tipo de contenido",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Personas hablando",
|
||||
"description": "Optimizado para personas ante la cámara con diapositivas ocasionales. Archivos más ligeros."
|
||||
},
|
||||
"text": {
|
||||
"label": "Presentaciones y texto",
|
||||
"description": "Mantiene el texto nítido al compartir diapositivas, documentos o código."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Contenido mixto",
|
||||
"description": "Equilibrado entre cámaras, pantalla compartida y movimiento moderado."
|
||||
},
|
||||
"full": {
|
||||
"label": "Calidad máxima",
|
||||
"description": "Máxima fidelidad y movimiento más fluido. Archivos más pesados."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Resolución",
|
||||
"description": "Las resoluciones más altas generan archivos más pesados.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
},
|
||||
"defaultOption": "Predeterminado"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,5 +68,9 @@
|
||||
"member": "Membre"
|
||||
}
|
||||
},
|
||||
"speakerChanged": {
|
||||
"granted": "Vous êtes maintenant intervenant. Vous pouvez utiliser votre micro, votre caméra et partager votre écran.",
|
||||
"removed": "Vous n'êtes plus intervenant."
|
||||
},
|
||||
"openMenu": "Ouvrir le menu"
|
||||
}
|
||||
|
||||
@@ -535,9 +535,7 @@
|
||||
"destination": "Cet enregistrement sera conservé temporairement sur nos serveurs",
|
||||
"loading": "Démarrage de l'enregistrement",
|
||||
"linkMore": "En savoir plus",
|
||||
"transcription": "Transcrire cet enregistrement",
|
||||
"quality": "Qualité vidéo ajustable.",
|
||||
"qualityEdit": "Modifier"
|
||||
"transcription": "Transcrire cet enregistrement"
|
||||
},
|
||||
"button": {
|
||||
"start": "Commencer à enregistrer la réunion",
|
||||
@@ -662,6 +660,7 @@
|
||||
"host": "Organisateur de la réunion",
|
||||
"cohost": "Co-organisateur",
|
||||
"member": "Membre",
|
||||
"speaker": "Intervenant",
|
||||
"collapsable": {
|
||||
"open": "Ouvrir la liste {{name}}",
|
||||
"close": "Fermer la liste {{name}}"
|
||||
@@ -716,6 +715,14 @@
|
||||
"demote": {
|
||||
"label": "Retirer le rôle de co-organisateur",
|
||||
"ariaLabel": "Retirer le rôle de co-organisateur à {{name}}"
|
||||
},
|
||||
"makeSpeaker": {
|
||||
"label": "Désigner comme intervenant",
|
||||
"ariaLabel": "Désigner {{name}} comme intervenant"
|
||||
},
|
||||
"removeSpeaker": {
|
||||
"label": "Retirer le rôle d'intervenant",
|
||||
"ariaLabel": "Retirer le rôle d'intervenant de {{name}}"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
|
||||
@@ -184,46 +184,10 @@
|
||||
"accessibility": "Accessibilité",
|
||||
"transcription": "Transcription",
|
||||
"shortcuts": "Raccourcis",
|
||||
"rooms": "Réunions",
|
||||
"recording": "Enregistrement"
|
||||
"rooms": "Réunions"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Paramètres par défaut des réunions",
|
||||
"description": "Choisissez les paramètres appliqués par défaut aux nouvelles réunions que vous créez. Vous pourrez toujours les modifier pour chaque réunion depuis les paramètres d’administration."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Qualité de l'enregistrement",
|
||||
"locked": "Ces paramètres ne peuvent pas être modifiés pendant le démarrage ou le déroulement d'un enregistrement.",
|
||||
"profile": {
|
||||
"label": "Type de contenu",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Visioconférence",
|
||||
"description": "Optimisé pour des personnes à la caméra avec quelques diapositives. Fichiers les plus légers."
|
||||
},
|
||||
"text": {
|
||||
"label": "Présentations et texte",
|
||||
"description": "Garde le texte net lors du partage de diapositives, de documents ou de code."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Contenu mixte",
|
||||
"description": "Équilibré entre caméras, partage d'écran et mouvements modérés."
|
||||
},
|
||||
"full": {
|
||||
"label": "Qualité maximale",
|
||||
"description": "Meilleure fidélité et mouvements les plus fluides. Fichiers les plus lourds."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Résolution",
|
||||
"description": "Une résolution plus élevée produit des fichiers plus lourds.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
},
|
||||
"defaultOption": "Par défaut"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,5 +68,9 @@
|
||||
"member": "Lid"
|
||||
}
|
||||
},
|
||||
"speakerChanged": {
|
||||
"granted": "Je bent nu spreker. Je kunt je microfoon, camera en schermdeling gebruiken.",
|
||||
"removed": "Je bent geen spreker meer."
|
||||
},
|
||||
"openMenu": "Menu openen"
|
||||
}
|
||||
|
||||
@@ -535,9 +535,7 @@
|
||||
"destination": "Deze opname wordt tijdelijk op onze servers bewaard",
|
||||
"loading": "Opname wordt gestart",
|
||||
"linkMore": "Meer informatie",
|
||||
"transcription": "Transcribeer deze opname",
|
||||
"quality": "Videokwaliteit aanpasbaar.",
|
||||
"qualityEdit": "Wijzigen"
|
||||
"transcription": "Transcribeer deze opname"
|
||||
},
|
||||
"button": {
|
||||
"start": "Start met opnemen van de vergadering",
|
||||
@@ -661,6 +659,7 @@
|
||||
"host": "Host",
|
||||
"cohost": "Co-host",
|
||||
"member": "Lid",
|
||||
"speaker": "Spreker",
|
||||
"contributors": "Deelnemers",
|
||||
"collapsable": {
|
||||
"open": "Open {{name}} lijst",
|
||||
@@ -716,6 +715,14 @@
|
||||
"demote": {
|
||||
"label": "Co-hostrol verwijderen",
|
||||
"ariaLabel": "De co-hostrol van {{name}} verwijderen"
|
||||
},
|
||||
"makeSpeaker": {
|
||||
"label": "Spreker maken",
|
||||
"ariaLabel": "{{name}} spreker maken"
|
||||
},
|
||||
"removeSpeaker": {
|
||||
"label": "Sprekersrol verwijderen",
|
||||
"ariaLabel": "Sprekersrol van {{name}} verwijderen"
|
||||
}
|
||||
},
|
||||
"pinAnnouncements": {
|
||||
|
||||
@@ -184,46 +184,10 @@
|
||||
"accessibility": "Toegankelijkheid",
|
||||
"transcription": "Transcriptie",
|
||||
"shortcuts": "Sneltoetsen",
|
||||
"rooms": "Vergaderingen",
|
||||
"recording": "Opname"
|
||||
"rooms": "Vergaderingen"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Standaardinstellingen voor vergaderingen",
|
||||
"description": "Kies de instellingen die standaard worden toegepast op nieuwe vergaderingen die u aanmaakt. U kunt ze voor elke vergadering nog steeds wijzigen via de hostinstellingen."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Opnamekwaliteit",
|
||||
"locked": "Deze instellingen kunnen niet worden gewijzigd terwijl een opname start of bezig is.",
|
||||
"profile": {
|
||||
"label": "Type inhoud",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Sprekende personen",
|
||||
"description": "Geoptimaliseerd voor mensen voor de camera met af en toe dia's. Kleinste bestanden."
|
||||
},
|
||||
"text": {
|
||||
"label": "Presentaties en tekst",
|
||||
"description": "Houdt tekst scherp bij het delen van dia's, documenten of code."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Gemengde inhoud",
|
||||
"description": "Gebalanceerd voor camera's, schermdeling en matige beweging."
|
||||
},
|
||||
"full": {
|
||||
"label": "Maximale kwaliteit",
|
||||
"description": "Hoogste getrouwheid en vloeiendste beweging. Grootste bestanden."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Resolutie",
|
||||
"description": "Hogere resoluties leveren grotere bestanden op.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
},
|
||||
"defaultOption": "Standaard"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import { type StyledVariantProps } from '@/styled-system/types'
|
||||
const StyledRadio = styled(RACRadio, {
|
||||
base: {
|
||||
display: 'flex',
|
||||
// anchor the visually hidden input, so it scrolls with the radio
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
gap: 0.375,
|
||||
forcedColorAdjust: 'none',
|
||||
|
||||
@@ -9,8 +9,6 @@ import { RiCheckLine, RiCloseFill } from '@remixicon/react'
|
||||
const StyledSwitch = styled(RACSwitch, {
|
||||
base: {
|
||||
display: 'flex',
|
||||
// anchor the visually hidden input, so it scrolls with the switch
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
gap: '0.571rem',
|
||||
color: 'black',
|
||||
|
||||
@@ -25,15 +25,12 @@ export const menuRecipe = sva({
|
||||
'&[data-focused]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
outline: 'none!',
|
||||
},
|
||||
'&[data-hovered]': {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
},
|
||||
'&[data-focus-visible]': {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'focusRing',
|
||||
outlineOffset: '2px',
|
||||
outline: 'none!',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -52,10 +49,6 @@ export const menuRecipe = sva({
|
||||
dark: {
|
||||
item: {
|
||||
color: 'white',
|
||||
'&[data-focus-visible]': {
|
||||
outlineColor: 'white!',
|
||||
outlineOffset: '2px!',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
import { STORAGE_KEYS } from '@/utils/storageKeys'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export type RecordingEncodingPreference = {
|
||||
resolution?: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording encoding preferences, shared by everyone using this browser.
|
||||
*/
|
||||
type State = RecordingEncodingPreference
|
||||
|
||||
const DEFAULT_STATE: State = {}
|
||||
|
||||
function getRecordingEncodingState(): State {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.RECORDING_ENCODING)
|
||||
if (!stored) return DEFAULT_STATE
|
||||
const parsed = JSON.parse(stored)
|
||||
if (!parsed) return DEFAULT_STATE
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
reportError('generic_failure', error, {
|
||||
context: '[RecordingEncodingStore] Failed to parse stored settings:',
|
||||
})
|
||||
return DEFAULT_STATE
|
||||
}
|
||||
}
|
||||
|
||||
export const recordingEncodingStore = proxy<State>(getRecordingEncodingState())
|
||||
|
||||
subscribe(recordingEncodingStore, () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.RECORDING_ENCODING,
|
||||
JSON.stringify(recordingEncodingStore)
|
||||
)
|
||||
})
|
||||
|
||||
export const setRecordingEncodingPreference = (
|
||||
preference: RecordingEncodingPreference
|
||||
) => {
|
||||
Object.assign(recordingEncodingStore, preference)
|
||||
}
|
||||
@@ -6,5 +6,4 @@ export const STORAGE_KEYS = {
|
||||
USER_PREFERENCES: 'app_user_preferences',
|
||||
USER: 'app_user',
|
||||
ACCESSIBILITY: 'app_accessibility_settings',
|
||||
RECORDING_ENCODING: 'app_recording_encoding',
|
||||
} as const
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.35.0",
|
||||
"version": "1.34.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.35.0"
|
||||
version = "1.34.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
|
||||
Generated
+1
-1
@@ -1484,7 +1484,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "summary"
|
||||
version = "1.35.0"
|
||||
version = "1.34.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "boto3" },
|
||||
|
||||
Reference in New Issue
Block a user