Compare commits

..

2 Commits

Author SHA1 Message Date
lebaudantoine c149c8ce9c 💄(frontend) add minor layout adjustments
Propose minor layout adjustments to ensure the DINUM version with French
copywriting does not look visually awkward due to line breaks.
2026-01-05 17:43:40 +01:00
lebaudantoine 198442b137 🐛(backend) fix certificates volume mount path for Python 3.13
After upgrading Python to 3.13, not all development environments were
updated accordingly. This fixes the incorrect volume mount path
introduced by that upgrade.
2026-01-05 17:43:37 +01:00
103 changed files with 575 additions and 2364 deletions
-16
View File
@@ -147,14 +147,6 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -186,14 +178,6 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
+3 -47
View File
@@ -8,63 +8,19 @@ and this project adheres to
## [Unreleased]
### Changed
- ♿️(frontend) add accessible back button in side panel #881
- ♿️(frontend) improve participants toggle a11y label #880
- ♿️(frontend) make carousel image decorative #871
- ♿️(frontend) reactions are now vocalized and configurable #849
- ♿️(frontend) improve background effect announcements #879
### Fixed
- 🔒(backend) prevent automatic upgrade setuptools
- ♿(frontend) improve contrast for selected options #863
- ♿️(frontend) announce copy state in invite dialog #877
- 📝(frontend) align close dialog label in rooms locale #878
- 🩹(backend) use case-insensitive email matching in the external api #887
## [1.3.0] - 2026-01-13
### Added
- ✨(summary) add dutch and german languages
- 🔧(agents) make Silero VAD optional
- 🚸(frontend) explain to a user they were ejected
### Changed
- 📈(frontend) track new recording's modes
- ♿️(frontend) improve accessibility of the background and effects menu
- ♿️(frontend) improve SR and focus for transcript and recording #810
- 💄(frontend) adjust spacing in the recording side panels
- 🚸(frontend) remove the default comma delimiter in humanized durations
### Fixed
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
- 🩹(frontend) icon font loading to avoid text/icon flickering
## [1.2.0] - 2026-01-05
### Added
- ✨(agent) support Kyutai client for subtitle
- ✨(frontend) remove the beta badge
- ✨(all) support starting transcription and recording simultaneously
- ✨(backend) persist options on a recording
- ✨(all) support choosing the transcription language
- ✨(summary) add a download link to the audio/video file
- ✨(frontend) allow unprivileged users to request a recording
### Changed
- 🚸(frontend) remove the beta badge
- ♻️(summary) extract file handling in a robust service
- ♻️(all) manage recording state on the backend side
## [1.1.0] - 2025-12-22
### Added
### Added
- ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls
@@ -76,6 +32,6 @@ and this project adheres to
- ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
+1 -1
View File
@@ -4,7 +4,7 @@
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apk update && \
+1 -1
View File
@@ -1,6 +1,6 @@
# Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
## Requirements
+1 -1
View File
@@ -1,7 +1,7 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
+4 -5
View File
@@ -31,7 +31,6 @@ logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
def create_stt_provider():
@@ -123,8 +122,9 @@ class MultiUserTranscriber:
if participant.identity in self._sessions:
return self._sessions[participant.identity]
vad = self.ctx.proc.userdata.get("vad", None)
session = AgentSession(vad=vad)
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
@@ -193,8 +193,7 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.3.0"
version = "1.1.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
-1
View File
@@ -1 +0,0 @@
"""Meet core add-ons module."""
-126
View File
@@ -1,126 +0,0 @@
"""Authentication session management for add-ons using temporary cache-based sessions."""
import secrets
from datetime import datetime, timedelta, timezone
from enum import Enum
from logging import getLogger
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import SuspiciousOperation
from core.models import User
from core.services.jwt_token_service import TokenService
logger = getLogger(__name__)
class SessionState(str, Enum):
"""Add-on authentication session states."""
PENDING = "pending"
AUTHENTICATED = "authenticated"
class TokenExchangeService:
"""Manage temporary authentication sessions for add-on JWT token exchange."""
def __init__(self):
"""Initialize the service with the configured token service."""
self._token_service = TokenService(
secret_key=settings.ADDONS_JWT_SECRET_KEY,
algorithm=settings.ADDONS_JWT_ALG,
issuer=settings.ADDONS_JWT_ISSUER,
audience=settings.ADDONS_JWT_AUDIENCE,
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
)
def _get_cache_key(self, session_id: str) -> str:
"""Generate cache key for a session ID."""
return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
def init_session(self) -> str:
"""Create a new pending authentication session and return its ID."""
session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
expires_at = datetime.now(timezone.utc) + timedelta(
seconds=settings.ADDONS_SESSION_TIMEOUT
)
session_data = {
"state": SessionState.PENDING,
"expires_at": expires_at.isoformat(),
}
cache_key = self._get_cache_key(session_id)
cache.set(
cache_key,
session_data,
timeout=settings.ADDONS_SESSION_TIMEOUT,
)
return session_id
def get_session(self, session_id: str) -> dict:
"""Retrieve session data and clear it if authenticated."""
cache_key = self._get_cache_key(session_id)
data = cache.get(cache_key)
if not data:
return {}
if data.get("state") == SessionState.AUTHENTICATED:
self.clear_session(session_id)
# Return copy without internal fields
internal_fields = {"expires_at"}
return {k: v for k, v in data.items() if k not in internal_fields}
def clear_session(self, session_id: str) -> None:
"""Remove session data from cache."""
cache_key = self._get_cache_key(session_id)
cache.delete(cache_key)
def set_access_token(self, user: User, session_id: str):
"""Generate and store access token for an authenticated user session."""
cache_key = self._get_cache_key(session_id)
existing_data = cache.get(cache_key)
if not existing_data:
raise SuspiciousOperation("Session not found.")
expires_at = existing_data.get("expires_at", None)
if not expires_at:
self.clear_session(session_id)
raise SuspiciousOperation("Invalid session data.")
remaining_seconds = int(
(
datetime.fromisoformat(expires_at) - datetime.now(timezone.utc)
).total_seconds()
)
if remaining_seconds <= 0:
self.clear_session(session_id)
raise SuspiciousOperation("Session expired.")
if existing_data.get("state") != SessionState.PENDING:
self.clear_session(session_id)
raise SuspiciousOperation("Access token already set.")
response = self._token_service.generate_access_token(
user, settings.ADDONS_SCOPES
)
new_data = {
**existing_data,
**response,
"state": SessionState.AUTHENTICATED,
}
cache.set(cache_key, new_data, timeout=remaining_seconds)
-57
View File
@@ -1,57 +0,0 @@
"""Add-ons views."""
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from core.addons.service import SessionState, TokenExchangeService
def render_error(request, message, status=400):
"""Render simple error page."""
return render(request, "addons/error.html", {"message": message}, status=status)
@require_http_methods(["GET"])
def transit_page(request):
"""Initialize authentication flow for add-on session."""
session_id = request.GET.get("session_id")
if not session_id:
return render_error(request, _("Session ID is required."), status=400)
data = TokenExchangeService().get_session(session_id)
if not data:
return render_error(request, _("Session not found or expired."), status=404)
if data.get("state") != SessionState.PENDING:
return render_error(request, _("Invalid session state."), status=400)
request.session[settings.ADDONS_SESSION_KEY_AUTH] = session_id
return_to = request.build_absolute_uri("/addons/redirect")
return redirect(f"/api/{settings.API_VERSION}/authenticate/?returnTo={return_to}")
@require_http_methods(["GET"])
def redirect_page(request):
"""Complete authentication and close the popup window."""
if not request.user.is_authenticated:
return render_error(request, _("Authentication required."), status=401)
session_id = request.session.pop(settings.ADDONS_SESSION_KEY_AUTH, None)
if not session_id:
return render_error(request, _("No active session found."), status=404)
try:
TokenExchangeService().set_access_token(request.user, session_id)
except SuspiciousOperation:
return render_error(request, _("Invalid or expired session."), status=400)
return render(request, "addons/redirect_success.html")
-53
View File
@@ -1,53 +0,0 @@
"""Add-ons API endpoints"""
from logging import getLogger
from rest_framework import (
response as drf_response,
)
from rest_framework import status as drf_status
from rest_framework import throttling, viewsets
from core.addons.service import TokenExchangeService
logger = getLogger(__name__)
class AuthSessionThrottle(throttling.AnonRateThrottle):
"""Throttle request to the addons auth session endpoints."""
scope = "addons_auth_sessions"
class AuthSessionViewSet(viewsets.ViewSet):
"""ViewSet for managing add-on authentication sessions via token exchange."""
authentication_classes = []
permission_classes = []
throttle_classes = [AuthSessionThrottle]
def create(self, request):
"""Create a new pending authentication session."""
session_id = TokenExchangeService().init_session()
return drf_response.Response(
{"session_id": session_id}, status=drf_status.HTTP_201_CREATED
)
def retrieve(self, request, pk=None):
"""Retrieve authentication session data by session ID."""
data = TokenExchangeService().get_session(pk)
if not data:
return drf_response.Response(
{"detail": "Session not found or expired."},
status=drf_status.HTTP_404_NOT_FOUND,
)
return drf_response.Response(data, status=drf_status.HTTP_200_OK)
def destroy(self, request, pk=None):
"""Delete an authentication session by session ID."""
TokenExchangeService().clear_session(pk)
return drf_response.Response(
{"status": "ok"}, status=drf_status.HTTP_204_NO_CONTENT
)
+42 -120
View File
@@ -14,13 +14,12 @@ User = get_user_model()
logger = logging.getLogger(__name__)
class BaseJWTAuthentication(authentication.BaseAuthentication):
"""Base JWT authentication class."""
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
"""JWT authentication for application-delegated API access.
secret_key = None
algorithm = None
issuer = None
audience = None
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
def authenticate(self, request):
"""Extract and validate JWT from Authorization header.
@@ -47,87 +46,6 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
return self.authenticate_credentials(token)
def decode_jwt(self, token):
"""Decode and validate JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict, or None if token is invalid
Raises:
AuthenticationFailed: If token is expired or has invalid issuer/audience
"""
try:
payload = pyJwt.decode(
token,
self.secret_key,
algorithms=[self.algorithm],
issuer=self.issuer,
audience=self.audience,
)
return payload
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None
def validate_payload(self, payload):
"""Validate JWT payload claims.
Override in subclasses to add custom validation.
Args:
payload: Decoded JWT payload
Raises:
AuthenticationFailed: If required claims are missing or invalid
"""
def get_user(self, payload):
"""Retrieve and validate user from payload.
Args:
payload: Decoded JWT payload
Returns:
User instance
Raises:
AuthenticationFailed: If user not found or inactive
"""
user_id = payload.get("user_id")
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
return user
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.
@@ -142,35 +60,36 @@ class BaseJWTAuthentication(authentication.BaseAuthentication):
Raises:
AuthenticationFailed: If token is expired, or user not found
"""
payload = self.decode_jwt(token)
if payload is None:
# Decode and validate JWT
try:
payload = pyJwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidAudienceError as e:
logger.warning("Invalid JWT audience: %s", e)
raise exceptions.AuthenticationFailed("Invalid token.") from e
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None
self.validate_payload(payload)
user = self.get_user(payload)
return (user, payload)
class ApplicationJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for application-delegated API access.
Validates JWT tokens issued to applications that are acting on behalf
of users. Tokens must include user_id, client_id, and delegation flag.
"""
secret_key = settings.APPLICATION_JWT_SECRET_KEY
algorithm = settings.APPLICATION_JWT_ALG
issuer = settings.APPLICATION_JWT_ISSUER
audience = settings.APPLICATION_JWT_AUDIENCE
def validate_payload(self, payload):
"""Validate application-specific claims."""
user_id = payload.get("user_id")
client_id = payload.get("client_id")
is_delegated = payload.get("delegated", False)
if not user_id:
logger.warning("Missing 'user_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
if not client_id:
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
@@ -179,18 +98,21 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as e:
logger.warning("User not found: %s", user_id)
raise exceptions.AuthenticationFailed("User not found.") from e
class AddonsJWTAuthentication(BaseJWTAuthentication):
"""JWT authentication for addons API access.
if not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user_id)
raise exceptions.AuthenticationFailed("User account is disabled.")
Validates JWT tokens issued by addons for authenticating users.
Tokens must include user_id to identify the authenticated user.
"""
return (user, payload)
secret_key = settings.ADDONS_JWT_SECRET_KEY
algorithm = settings.ADDONS_JWT_ALG
issuer = settings.ADDONS_JWT_ISSUER
audience = settings.ADDONS_JWT_AUDIENCE
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
return "Bearer"
class ResourceServerBackend(LaSuiteBackend):
+25 -24
View File
@@ -1,12 +1,14 @@
"""External API endpoints"""
from datetime import datetime, timedelta, timezone
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
import jwt
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
@@ -20,7 +22,6 @@ from rest_framework import (
)
from core import api, models
from core.services.jwt_token_service import TokenService
from . import authentication, permissions, serializers
@@ -92,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
try:
user = models.User.objects.get(email__iexact=email)
user = models.User.objects.get(email=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
@@ -122,33 +123,34 @@ class ApplicationViewSet(viewsets.GenericViewSet):
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
token_service = TokenService(
secret_key=settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
expiration_seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS,
token_type=settings.APPLICATION_JWT_TOKEN_TYPE,
)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": client_id,
"scope": scope,
"user_id": str(user.id),
"delegated": True,
}
data = token_service.generate_access_token(
user,
scope,
{
"client_id": client_id,
"delegated": True,
},
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
return drf_response.Response(
data,
{
"access_token": token,
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": scope,
},
status=drf_status.HTTP_200_OK,
)
@@ -173,7 +175,6 @@ class RoomViewSet(
authentication_classes = [
authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
@@ -1,97 +0,0 @@
"""JWT token service."""
# pylint: disable=R0913,R0917
# ruff: noqa: PLR0913
from datetime import datetime, timedelta, timezone
from typing import Optional
from django.core.exceptions import ImproperlyConfigured
import jwt
class TokenService:
"""Generic JWT token service with configurable settings."""
def __init__(
self,
secret_key: str,
algorithm: str,
issuer: str,
audience: str,
expiration_seconds: int,
token_type: str,
):
"""
Initialize the token service with custom settings.
Args:
secret_key: Secret key for JWT encoding/decoding
algorithm: JWT algorithm (default: HS256)
issuer: Token issuer identifier
audience: Token audience identifier
expiration_seconds: Token expiration time in seconds (default: 3600)
token_type: Token type (default: Bearer)
Raises:
ImproperlyConfigured: If secret_key is None or empty
"""
if not secret_key:
raise ImproperlyConfigured("Secret key is required.")
self._key = secret_key
self._alg = algorithm
self._issuer = issuer
self._audience = audience
self._expiration_seconds = expiration_seconds
self._token_type = token_type
def generate_access_token(
self, user, scope: str, extra_payload: Optional[dict] = None
) -> dict:
"""
Generate an access token for the given user.
Args:
user: User instance for whom to generate the token
scope: Space-separated scope string
Returns:
Dictionary containing access_token, token_type, expires_in, and scope
"""
now = datetime.now(timezone.utc)
payload = extra_payload.copy() if extra_payload else {}
payload.update(
{
"iat": now,
"exp": now + timedelta(seconds=self._expiration_seconds),
"user_id": str(user.id),
}
)
if self._issuer:
payload["iss"] = self._issuer
if self._audience:
payload["aud"] = self._audience
if scope:
payload["scope"] = scope
token = jwt.encode(
payload,
self._key,
algorithm=self._alg,
)
response = {
"access_token": token,
"token_type": self._token_type,
"expires_in": self._expiration_seconds,
}
if scope:
response["scope"] = scope
return response
@@ -1,17 +0,0 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Error" %}</title>
</head>
<body>
<div class="container">
<h1>{{ title|default:_("Error") }}</h1>
<p>{{ message|default:_("Something went wrong.") }}</p>
<button onclick="window.close()">{% trans "Close" %}</button>
</div>
</body>
</html>
@@ -1,17 +0,0 @@
{% load i18n %}
{% get_current_language as LANGUAGE %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE }}">
<head>
<meta charset="UTF-8">
<title>{% trans "Authentication Success" %}</title>
</head>
<body>
<script>
window.close();
</script>
<p>{% trans "Session stored successfully. This window will close automatically." %}</p>
<p>{% trans "If it doesn't close" %}, <a href="javascript:window.close()">{% trans "click here" %}</a>.</p>
</body>
</html>
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
UserFactory(email="User.Family@example.com")
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user.family@example.com",
"scope": user.email,
},
format="json",
)
-28
View File
@@ -6,8 +6,6 @@ from django.urls import include, path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.addons import views as addons_views
from core.addons import viewsets as addons_viewsets
from core.api import get_frontend_configuration, viewsets
from core.external_api import viewsets as external_viewsets
@@ -28,24 +26,12 @@ external_router.register(
basename="external_application",
)
# - Addons API
addons_router = DefaultRouter()
addons_router.register(
"addons/sessions",
addons_viewsets.AuthSessionViewSet,
basename="addons_auth_sessions",
)
external_router.register(
"rooms",
external_viewsets.RoomViewSet,
basename="external_room",
)
addons_urls = addons_router.urls if settings.ADDONS_ENABLED else []
urlpatterns = [
path(
f"api/{settings.API_VERSION}/",
@@ -53,26 +39,12 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*addons_urls,
path("config/", get_frontend_configuration, name="config"),
]
),
),
]
if settings.ADDONS_ENABLED:
urlpatterns.append(
path(
"addons/",
include(
[
path("transit/", addons_views.transit_page, name="transit_page"),
path("redirect/", addons_views.redirect_page, name="redirect_page"),
]
),
),
)
if settings.EXTERNAL_API_ENABLED:
urlpatterns.append(
path(
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sitzungs-ID ist erforderlich."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sitzung nicht gefunden oder abgelaufen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ungültiger Sitzungsstatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentifizierung erforderlich."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Keine aktive Sitzung gefunden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ungültige oder abgelaufene Sitzung."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persönliche Informationen"
@@ -432,7 +408,7 @@ msgstr "Anwendungsdomain"
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -441,30 +417,6 @@ msgstr "Ihre Aufzeichnung ist bereit"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fehler"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Etwas ist schiefgelaufen."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Schließen"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentifizierung erfolgreich"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sitzung erfolgreich gespeichert. Dieses Fenster wird automatisch geschlossen."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Falls es sich nicht schließt"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Session ID is required."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session not found or expired."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Invalid session state."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentication required."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "No active session found."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Invalid or expired session."
#: core/admin.py:29
msgid "Personal info"
msgstr "Personal info"
@@ -429,7 +405,7 @@ msgstr "Application domain"
msgid "Application domains"
msgstr "Application domains"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -438,30 +414,6 @@ msgstr "Your recording is ready"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Error"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Something went wrong."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Close"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentication Success"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session stored successfully. This window will close automatically."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "If it doesn't close"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "L'identifiant de session est requis."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Session introuvable ou expirée."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "État de session invalide."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authentification requise."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Aucune session active trouvée."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Session invalide ou expirée."
#: core/admin.py:29
msgid "Personal info"
msgstr "Informations personnelles"
@@ -433,7 +409,7 @@ msgstr "Domaine dapplication"
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -442,30 +418,6 @@ msgstr "Votre enregistrement est prêt"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Erreur"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Une erreur s'est produite."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Fermer"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authentification réussie"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Session enregistrée avec succès. Cette fenêtre se fermera automatiquement."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Si elle ne se ferme pas"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
Binary file not shown.
+2 -50
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,30 +17,6 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/addons/views.py:24
msgid "Session ID is required."
msgstr "Sessie-ID is vereist."
#: core/addons/views.py:29
msgid "Session not found or expired."
msgstr "Sessie niet gevonden of verlopen."
#: core/addons/views.py:32
msgid "Invalid session state."
msgstr "Ongeldige sessiestatus."
#: core/addons/views.py:45
msgid "Authentication required."
msgstr "Authenticatie vereist."
#: core/addons/views.py:50
msgid "No active session found."
msgstr "Geen actieve sessie gevonden."
#: core/addons/views.py:55
msgid "Invalid or expired session."
msgstr "Ongeldige of verlopen sessie."
#: core/admin.py:29
msgid "Personal info"
msgstr "Persoonlijke informatie"
@@ -428,7 +404,7 @@ msgstr "Applicatiedomein"
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -437,30 +413,6 @@ msgstr "Je opname is klaar"
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
msgid "Error"
msgstr "Fout"
#: core/templates/addons/error.html:12
msgid "Something went wrong."
msgstr "Er is iets misgegaan."
#: core/templates/addons/error.html:13
msgid "Close"
msgstr "Sluiten"
#: core/templates/addons/redirect_success.html:7
msgid "Authentication Success"
msgstr "Authenticatie geslaagd"
#: core/templates/addons/redirect_success.html:13
msgid "Session stored successfully. This window will close automatically."
msgstr "Sessie succesvol opgeslagen. Dit venster wordt automatisch gesloten."
#: core/templates/addons/redirect_success.html:14
msgid "If it doesn't close"
msgstr "Als het niet sluit"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
-65
View File
@@ -290,11 +290,6 @@ class Base(Configuration):
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
environ_prefix=None,
),
"addons_auth_sessions": values.Value(
default="150/minute",
environ_name="ADDONS_AUTH_SESSION_THROTTLE_RATES",
environ_prefix=None,
),
},
}
@@ -791,66 +786,6 @@ class Base(Configuration):
environ_prefix=None,
)
# Addons
ADDONS_ENABLED = values.BooleanValue(
False,
environ_name="ADDONS_ENABLED",
environ_prefix=None,
)
ADDONS_SESSION_ID_LENGTH = values.PositiveIntegerValue(
32,
environ_name="ADDONS_SESSION_ID_LENGTH",
environ_prefix=None,
)
# Used in cache key generation
ADDONS_SESSION_KEY_PREFIX = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_PREFIX",
environ_prefix=None,
)
# Used as the Django session key in transit page
ADDONS_SESSION_KEY_AUTH = values.Value(
"addons_session_id",
environ_name="ADDONS_SESSION_KEY_AUTH",
environ_prefix=None,
)
ADDONS_SESSION_TIMEOUT = values.PositiveIntegerValue(
600, environ_name="ADDONS_SESSION_TIMEOUT", environ_prefix=None
)
ADDONS_JWT_SECRET_KEY = SecretFileValue(
None, environ_name="ADDONS_JWT_SECRET_KEY", environ_prefix=None
)
ADDONS_JWT_ALG = values.Value(
"HS256",
environ_name="ADDONS_JWT_ALG",
environ_prefix=None,
)
ADDONS_SCOPES = values.Value(
"rooms:create rooms:list",
environ_name="ADDONS_SCOPES",
environ_prefix=None,
)
ADDONS_JWT_ISSUER = values.Value(
"lasuite-meet",
environ_name="ADDONS_JWT_ISSUER",
environ_prefix=None,
)
ADDONS_JWT_AUDIENCE = values.Value(
None,
environ_name="ADDONS_JWT_AUDIENCE",
environ_prefix=None,
)
ADDONS_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
3600,
environ_name="ADDONS_JWT_EXPIRATION_SECONDS",
environ_prefix=None,
)
ADDONS_JWT_TOKEN_TYPE = values.Value(
"Bearer",
environ_name="ADDONS_JWT_TOKEN_TYPE",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.3.0"
version = "1.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -56,7 +56,7 @@ dependencies = [
"whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"aiohttp==3.13.3",
"aiohttp==3.13.2",
]
[project.urls]
-16
View File
@@ -6,22 +6,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
type="font/woff2"
/>
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
type="font/woff2"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.3.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.3.0",
"version": "1.1.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.3.0",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ export const BlurOnStrong = () => {
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fillRule="evenodd"
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex} key={index}>
<Image src={slide.src} alt="" role="presentation" />
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body>
@@ -111,13 +111,15 @@ export const TermsOfServiceRoute = () => {
))}
{/* Article 7 */}
<H lvl={2}>{t('articles.article7.title')}</H>
<P>{t('articles.article7.content')}</P>
<H lvl={2} margin={false}>
{t('articles.article7.title')}
</H>
{/* Section 7.1 */}
<H lvl={3} bold>
{t('articles.article7.sections.section1.title')}
</H>
<P>{t('articles.article7.sections.section1.content')}</P>
{ensureArray(
t('articles.article7.sections.section1.paragraphs', {
returnObjects: true,
@@ -130,51 +132,16 @@ export const TermsOfServiceRoute = () => {
<H lvl={3} bold>
{t('articles.article7.sections.section2.title')}
</H>
{ensureArray(
t('articles.article7.sections.section2.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.3 */}
<H lvl={3} bold>
{t('articles.article7.sections.section3.title')}
</H>
{ensureArray(
t('articles.article7.sections.section3.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.4 */}
<H lvl={3} bold>
{t('articles.article7.sections.section4.title')}
</H>
{ensureArray(
t('articles.article7.sections.section4.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
{/* Section 7.5 */}
<H lvl={3} bold>
{t('articles.article7.sections.section5.title')}
</H>
<P>
{t('articles.article7.sections.section5.content')
{t('articles.article7.sections.section2.content')
.split('https://github.com/suitenumerique/meet')[0]
.replace('https://github.com/suitenumerique/meet', '')}{' '}
<A href="https://github.com/suitenumerique/meet" color="primary">
https://github.com/suitenumerique/meet
</A>
{'. '}
{
t('articles.article7.sections.section5.content').split(
t('articles.article7.sections.section2.content').split(
'https://github.com/suitenumerique/meet'
)[1]
}
@@ -1,7 +1,7 @@
import { css } from '@/styled-system/css'
import { css, cx } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner'
import { Button, Icon, Text } from '@/primitives'
import { Button, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react'
@@ -42,17 +42,6 @@ export const ControlsButton = ({
}: ControlsButtonProps) => {
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
// Focus management: focus the primary action button when this side panel opens.
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
requestAnimationFrame(() => {
if (primaryActionRef.current) {
primaryActionRef.current.focus({ preventScroll: true })
}
})
}, [])
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
@@ -108,7 +97,6 @@ export const ControlsButton = ({
fullWidth
onPress={handle}
isDisabled={isDisabled}
ref={primaryActionRef}
>
{t('button.stop')}
</Button>
@@ -141,23 +129,31 @@ export const ControlsButton = ({
})}
onPress={() => openSidePanel()}
>
<Icon
className={css({
color: 'primary.500',
marginRight: '1rem',
})}
name="info"
/>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginRight: '1rem',
})
)}
>
info
</span>
<Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))}
</Text>
<Icon
className={css({
color: 'primary.500',
marginLeft: 'auto',
})}
name="chevron_right"
/>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginLeft: 'auto',
})
)}
>
chevron_right
</span>
</RACButton>
)}
<Button
@@ -166,7 +162,6 @@ export const ControlsButton = ({
onPress={handle}
isDisabled={isDisabled}
size="compact"
ref={primaryActionRef}
>
{t('button.start')}
</Button>
@@ -1,4 +1,4 @@
import { H, Text, Icon } from '@/primitives'
import { H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { LoginButton } from '@/components/LoginButton'
import { HStack } from '@/styled-system/jsx'
@@ -24,7 +24,9 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<Icon type="symbols" name="login" />
<span className="material-symbols" aria-hidden={true}>
login
</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useMemo, useRef, useState, useEffect } from 'react'
import { useMemo } from 'react'
import { Text } from '@/primitives'
import {
RecordingMode,
@@ -21,9 +21,6 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel()
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
@@ -70,23 +67,6 @@ export const RecordingStateToast = () => {
return `${metadata.recording_mode}.${status}`
}, [metadata, isStarted, isStarting, isRecording])
// Update screen reader message only when the key actually changes
// This prevents duplicate announcements caused by re-renders
useEffect(() => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
}
}, [key, t])
if (!key) return null
const hasScreenRecordingAccessAndActive =
@@ -94,74 +74,61 @@ export const RecordingStateToast = () => {
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return (
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
{!hasScreenRecordingAccessAndActive &&
!hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
</>
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
)
}
@@ -1,5 +1,4 @@
import { Spinner } from '@/primitives/Spinner'
import { Icon } from '@/primitives'
interface RecordingStatusIconProps {
isStarted: boolean
@@ -15,8 +14,8 @@ export const RecordingStatusIcon = ({
}
if (isTranscriptActive) {
return <Icon type="symbols" name="speech_to_text" />
return <span className="material-symbols">speech_to_text</span>
}
return <Icon type="symbols" name="screen_record" />
return <span className="material-symbols">screen_record</span>
}
@@ -1,4 +1,4 @@
import { Button, Icon, H, Text } from '@/primitives'
import { Button, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
@@ -59,7 +59,7 @@ export const RequestRecording = ({
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<Icon type="symbols" name="person_raised_hand" />
<span className="material-symbols">person_raised_hand</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
@@ -1,6 +1,5 @@
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
import { Icon } from '@/primitives'
type RowPosition = 'first' | 'middle' | 'last' | 'single'
@@ -46,7 +45,7 @@ export const RowWrapper = ({
})}
>
{/* fixme - doesn't handle properly material-symbols */}
<Icon name={iconName} />
<span className="material-icons">{iconName}</span>
</div>
<div
className={css({
@@ -5,6 +5,7 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasFeatureWithoutAdminRights,
useHumanizeRecordingMaxDuration,
useRecordingStatuses,
} from '@/features/recording'
@@ -18,6 +19,7 @@ import {
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useConfig } from '@/api/useConfig'
import { FeatureFlags } from '@/features/analytics/enums'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
@@ -26,7 +28,6 @@ import { Checkbox } from '@/primitives/Checkbox'
import { useTranscriptionLanguage } from '@/features/settings'
import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
@@ -37,7 +38,10 @@ export const ScreenRecordingSidePanel = () => {
const [includeTranscript, setIncludeTranscript] = useState(false)
const isAdminOrOwner = useIsAdminOrOwner()
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const { notifyParticipants } = useNotifyParticipants()
const { selectedLanguageKey, isLanguageSetToAuto } =
@@ -94,17 +98,14 @@ export const ScreenRecordingSidePanel = () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingStarted,
})
posthog.capture('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
})
posthog.capture('screen-recording-started', {})
}
} catch (error) {
console.error('Failed to handle recording:', error)
}
}
if (!isAdminOrOwner) {
if (hasFeatureWithoutAdminRights) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
@@ -145,7 +146,7 @@ export const ScreenRecordingSidePanel = () => {
},
})}
/>
<VStack gap={0} marginBottom={15}>
<VStack gap={0} marginBottom={30}>
<H lvl={1} margin={'sm'} fullWidth>
{t('heading')}
</H>
@@ -162,7 +163,7 @@ export const ScreenRecordingSidePanel = () => {
)}
</Text>
</VStack>
<VStack gap={0} marginBottom={25}>
<VStack gap={0} marginBottom={40}>
<RowWrapper iconName="cloud_download" position="first">
<Text variant="sm">{t('details.destination')}</Text>
</RowWrapper>
@@ -117,10 +117,7 @@ export const TranscriptSidePanel = () => {
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
posthog.capture('transcript-started', {
includeScreenRecording: includeScreenRecording,
language: selectedLanguageKey,
})
posthog.capture('transcript-started', {})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
@@ -181,7 +178,7 @@ export const TranscriptSidePanel = () => {
},
})}
/>
<VStack gap={0} marginBottom={15}>
<VStack gap={0} marginBottom={30}>
<H lvl={1} margin={'sm'}>
{t('heading')}
</H>
@@ -198,7 +195,7 @@ export const TranscriptSidePanel = () => {
)}
</Text>
</VStack>
<VStack gap={0} marginBottom={25}>
<VStack gap={0} marginBottom={40}>
<RowWrapper iconName="article" position="first">
<Text variant="sm">
{data?.transcription_destination ? (
@@ -11,7 +11,6 @@ export const useHumanizeRecordingMaxDuration = () => {
return humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
delimiter: ' ',
})
}, [data])
}
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices'
import { navigateTo } from '@/navigation/navigateTo'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
import { usePostHog } from 'posthog-js/react'
import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
export const Conference = ({
roomId,
@@ -228,20 +228,10 @@ export const Conference = ({
posthog.captureException(e)
}}
onDisconnected={(e) => {
switch (e) {
case DisconnectReason.CLIENT_INITIATED:
navigateTo('feedback')
return
case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED:
navigateTo(
'feedback',
{},
{
state: { reason: e },
}
)
return
if (e == DisconnectReason.CLIENT_INITIATED) {
navigateTo('feedback', { duplicateIdentity: false })
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
navigateTo('feedback', { duplicateIdentity: true })
}
}}
onMediaDeviceFailure={(e, kind) => {
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
@@ -138,12 +138,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
@@ -174,7 +173,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
aria-label={t('copy')}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
@@ -117,9 +117,7 @@ export const ParticipantTile: (
'aria-label': t('containerLabel', { name: participantName }),
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onFocus?.(event)
const target = event.target as HTMLElement | null
const isFocusVisible = !!target?.matches?.(':focus-visible')
setHasKeyboardFocus(isFocusVisible)
setHasKeyboardFocus(true)
},
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event)
@@ -5,9 +5,6 @@ import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
@@ -143,53 +140,11 @@ export function ReactionPortal({
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [announcement, setAnnouncement] = useState<string | null>(null)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const latestReaction =
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
setAnnouncement(null)
return
}
if (!latestReaction) return
const isNewReaction = latestReaction.id !== lastAnnouncedId
if (!isNewReaction) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participant?.isLocal
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
const timer = setTimeout(() => setAnnouncement(null), 1200)
return () => clearTimeout(timer)
}, [latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
{reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement ?? ''}
</div>
</>
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))
@@ -13,32 +13,27 @@ import { Effects } from './effects/Effects'
import { Admin } from './Admin'
import { Tools } from './Tools'
import { Info } from './Info'
import { HStack } from '@/styled-system/jsx'
type StyledSidePanelProps = {
title: string
ariaLabel: string
children: ReactNode
onClose: () => void
isClosed: boolean
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
backButtonLabel: string
}
const StyledSidePanel = ({
title,
ariaLabel,
children,
onClose,
isClosed,
closeButtonTooltip,
isSubmenu = false,
onBack,
backButtonLabel,
}: StyledSidePanelProps) => (
<aside
<div
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -63,37 +58,32 @@ const StyledSidePanel = ({
style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<HStack alignItems="center">
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
size={'sm'}
square
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
className={css({ marginRight: '0.5rem' })}
onPress={onBack}
>
<RiArrowLeftLine size={20} aria-hidden="true" />
<RiArrowLeftLine size={20} />
</Button>
)}
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{title}
</Heading>
</HStack>
{title}
</Heading>
<Div
position="absolute"
top="5"
@@ -114,7 +104,7 @@ const StyledSidePanel = ({
</Button>
</Div>
{children}
</aside>
</div>
)
type PanelProps = {
@@ -135,6 +125,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
const {
activePanelId,
@@ -153,7 +144,6 @@ export const SidePanel = () => {
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
@@ -163,7 +153,6 @@ export const SidePanel = () => {
})}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
>
<Panel isOpen={isParticipantsOpen}>
@@ -175,7 +164,7 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Panel isOpen={isToolsOpen}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
@@ -1,10 +1,9 @@
import { A, Div, Icon, Text } from '@/primitives'
import { A, Div, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import {
useIsRecordingModeEnabled,
RecordingMode,
@@ -87,7 +86,7 @@ const ToolButton = ({
alignItems: 'center',
})}
>
<Icon type="symbols" name="chevron_forward" />
<span className="material-symbols">chevron_forward</span>
</div>
</RACButton>
)
@@ -95,26 +94,10 @@ const ToolButton = ({
export const Tools = () => {
const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
preventScroll: true,
})
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
@@ -163,7 +146,7 @@ export const Tools = () => {
</Text>
{isTranscriptEnabled && (
<ToolButton
icon={<Icon type="symbols" name="speech_to_text" />}
icon={<span className="material-symbols">speech_to_text</span>}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
@@ -171,7 +154,7 @@ export const Tools = () => {
)}
{isScreenRecordingEnabled && (
<ToolButton
icon={<Icon type="symbols" name="mode_standby" />}
icon={<span className="material-symbols">mode_standby</span>}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
@@ -9,7 +9,6 @@ export const OptionsButton = () => {
return (
<Menu variant="dark">
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
@@ -1,7 +1,6 @@
import { useTranslation } from 'react-i18next'
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { css } from '@/styled-system/css'
import { useParticipants } from '@livekit/components-react'
import { useSidePanel } from '../../../hooks/useSidePanel'
@@ -20,8 +19,6 @@ export const ParticipantsToggle = ({
*/
const participants = useParticipants()
const numParticipants = participants?.length
const announcedCount =
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
@@ -34,24 +31,21 @@ export const ParticipantsToggle = ({
display: 'inline-block',
})}
>
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
<ToggleButton
square
variant="primaryTextDark"
aria-label={`${t(tooltipLabel)}. ${t('count', {
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
</VisualOnlyTooltip>
<ToggleButton
square
variant="primaryTextDark"
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
<div
className={css({
position: 'absolute',
@@ -10,7 +10,6 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
@@ -146,7 +145,7 @@ export const ReactionsToggle = () => {
<Button
key={index}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
aria-label={t('send', { emoji })}
variant="primaryTextDark"
size="sm"
square
@@ -9,13 +9,13 @@ import {
} from '../blur'
import { css } from '@/styled-system/css'
import { H, P, Text, ToggleButton } from '@/primitives'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { styled } from '@/styled-system/jsx'
import { BlurOn } from '@/components/icons/BlurOn'
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
@@ -50,17 +50,11 @@ export const EffectsConfiguration = ({
layout = 'horizontal',
}: EffectsConfigurationProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const blurLightRef = useRef<HTMLButtonElement | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const [effectStatusMessage, setEffectStatusMessage] = useState('')
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const effectAnnouncementId = useRef(0)
useEffect(() => {
const videoElement = videoRef.current
@@ -75,95 +69,16 @@ export const EffectsConfiguration = ({
}
}, [videoTrack, videoTrack?.isMuted])
useEffect(() => {
if (!blurLightRef.current) return
const rafId = requestAnimationFrame(() => {
blurLightRef.current?.focus({ preventScroll: true })
})
return () => {
cancelAnimationFrame(rafId)
}
}, [])
useEffect(
() => () => {
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
},
[]
)
const announceEffectStatusMessage = (message: string) => {
effectAnnouncementId.current += 1
const currentId = effectAnnouncementId.current
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setEffectStatusMessage('')
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
setEffectStatusMessage(message)
}, 80)
}
const clearEffect = async () => {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
}
const getVirtualBackgroundName = (imagePath?: string) => {
if (!imagePath) return ''
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
if (!match) return ''
const index = Number(match[1]) - 1
if (Number.isNaN(index)) return ''
return t(`virtual.descriptions.${index}`)
}
const updateEffectStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (wasSelectedBeforeToggle) {
announceEffectStatusMessage(t('blur.status.none'))
return
}
if (type === ProcessorType.BLUR) {
const message =
options.blurRadius === BlurRadius.LIGHT
? t('blur.status.light')
: t('blur.status.strong')
announceEffectStatusMessage(message)
return
}
if (type === ProcessorType.VIRTUAL) {
const backgroundName = getVirtualBackgroundName(options.imagePath)
if (backgroundName) {
announceEffectStatusMessage(
`${t('virtual.selectedLabel')} ${backgroundName}`
)
return
}
}
}
const toggleEffect = async (
type: ProcessorType,
options: BackgroundOptions
) => {
setProcessorPending(true)
const wasSelectedBeforeToggle = isSelected(type, options)
if (!videoTrack) {
/**
* Special case: if no video track is available, then we must pass directly the processor into the
@@ -189,7 +104,7 @@ export const EffectsConfiguration = ({
const processor = getProcessor()
try {
if (wasSelectedBeforeToggle) {
if (isSelected(type, options)) {
// Stop processor.
await clearEffect()
} else if (
@@ -216,8 +131,6 @@ export const EffectsConfiguration = ({
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
}
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
@@ -240,28 +153,8 @@ export const EffectsConfiguration = ({
)
}
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
const strength =
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
const action = isSelected(type, options) ? 'clear' : 'apply'
return t(`${type}.${strength}.${action}`)
}
const ariaLabelVirtualBackground = (
index: number,
imagePath: string
): string => {
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
imagePath,
})
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
const backgroundName = t(`virtual.descriptions.${index}`)
return `${t(`virtual.${prefix}`)} ${backgroundName}`
}
const tooltipVirtualBackground = (index: number): string => {
return t(`virtual.descriptions.${index}`)
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
}
return (
@@ -372,60 +265,65 @@ export const EffectsConfiguration = ({
>
{t('blur.title')}
</H>
<div>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
aria-label={t('clear')}
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal || isDisabled}
>
<ToggleButton
ref={blurLightRef}
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
<RiProhibited2Line />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipBlur(ProcessorType.BLUR, {
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.LIGHT,
})}
data-attr="toggle-blur-light"
>
<BlurOn />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipLabel(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
tooltip={tooltipBlur(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{effectStatusMessage}
</div>
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div
className={css({
@@ -445,37 +343,39 @@ export const EffectsConfiguration = ({
className={css({
display: 'flex',
gap: '1.25rem',
paddingBottom: '0.5rem',
flexWrap: 'wrap',
})}
>
{[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
const tooltipText = tooltipVirtualBackground(i)
return (
<VisualOnlyTooltip key={i} tooltip={tooltipText}>
<ToggleButton
variant="bigSquare"
aria-label={ariaLabelVirtualBackground(i, imagePath)}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
<ToggleButton
key={i}
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPendingReveal || isDisabled}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
</VisualOnlyTooltip>
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
)
})}
</div>
@@ -1,5 +1,5 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import React from 'react'
import React, { useEffect } from 'react'
import {
formatChatMessageLinks,
useChat,
@@ -15,7 +15,6 @@ import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel'
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>,
@@ -39,16 +38,33 @@ export function Chat({ ...props }: ChatProps) {
// Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed.
useRestoreFocus(isChatOpen, {
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
onOpened: () => {
const prevIsChatOpenRef = React.useRef(false)
const chatTriggerRef = React.useRef<HTMLElement | null>(null)
useEffect(() => {
const wasChatOpen = prevIsChatOpenRef.current
const isChatPanelOpen = isChatOpen
// Chat just opened
if (!wasChatOpen && isChatPanelOpen) {
chatTriggerRef.current = document.activeElement as HTMLElement | null
// Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
},
preventScroll: true,
})
}
// Chat just closed
if (wasChatOpen && !isChatPanelOpen) {
const trigger = chatTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
}
chatTriggerRef.current = null
}
prevIsChatOpenRef.current = isChatPanelOpen
}, [isChatOpen])
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()
@@ -73,7 +73,6 @@ export function MobileControlBar({
/>
<HandToggle />
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
@@ -37,7 +37,6 @@ export const LateralMenu = () => {
return (
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button
id="controlbar-more-options-trigger"
square
variant="secondaryDark"
aria-label={t('controls.moreOptions')}
@@ -1,24 +0,0 @@
import { useTranslation } from 'react-i18next'
export const getEmojiLabel = (
emoji: string,
t: ReturnType<typeof useTranslation>['t']
) => {
const emojiLabels: Record<string, string> = {
'thumbs-up': t('emojis.thumbs-up', { defaultValue: 'thumbs up' }),
'thumbs-down': t('emojis.thumbs-down', { defaultValue: 'thumbs down' }),
'clapping-hands': t('emojis.clapping-hands', {
defaultValue: 'clapping hands',
}),
'red-heart': t('emojis.red-heart', { defaultValue: 'red heart' }),
'face-with-tears-of-joy': t('emojis.face-with-tears-of-joy', {
defaultValue: 'face with tears of joy',
}),
'face-with-open-mouth': t('emojis.face-with-open-mouth', {
defaultValue: 'surprised face',
}),
'party-popper': t('emojis.party-popper', { defaultValue: 'party popper' }),
'folded-hands': t('emojis.folded-hands', { defaultValue: 'folded hands' }),
}
return emojiLabels[emoji] ?? emoji
}
@@ -3,9 +3,7 @@ import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter'
import { useMemo } from 'react'
import { DisconnectReason } from 'livekit-client'
import { useLocation, useSearchParams } from 'wouter'
// fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', {
@@ -22,40 +20,25 @@ const Heading = styled('h1', {
},
})
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
}
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
const [, setLocation] = useLocation()
const reasonKey = useMemo(() => {
const state = window.history.state
if (!state?.reason) return
switch (state.reason) {
case DisconnectReason.DUPLICATE_IDENTITY:
return DisconnectReasonKey.DuplicateIdentity
case DisconnectReason.PARTICIPANT_REMOVED:
return DisconnectReasonKey.ParticipantRemoved
}
}, [])
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
const [searchParams] = useSearchParams()
return (
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<HStack>
{showBackButton && (
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
<Heading>
{t(
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
)}
</Heading>
<HStack>
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
<Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')}
</Button>
@@ -2,7 +2,6 @@ import { Dialog, type DialogProps } from '@/primitives'
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx'
import { Icon } from '@/primitives/Icon'
import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
@@ -11,7 +10,6 @@ import {
RiSettings3Line,
RiSpeakerLine,
RiVideoOnLine,
RiEyeLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
@@ -23,7 +21,6 @@ import { useRef } from 'react'
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'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -109,16 +106,11 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon type="symbols" name="speech_to_text" />
<span className="material-symbols">speech_to_text</span>
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
@@ -132,7 +124,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
</div>
</Tabs>
</Dialog>
@@ -1,40 +0,0 @@
import { Field, H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { accessibilityStore } from '@/stores/accessibility'
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
const { t } = useTranslation('settings')
const snap = useSnapshot(accessibilityStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('tabs.accessibility')}</H>
<ul
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
<li>
<Field
type="switch"
label={t('accessibility.announceReactions.label')}
isSelected={snap.announceReactions}
onChange={(value) => {
accessibilityStore.announceReactions = value
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</li>
</ul>
</TabPanel>
)
}
export default AccessibilityTab
@@ -18,14 +18,6 @@ export const useTranscriptionLanguage = () => {
key: RecordingLanguage.ENGLISH,
label: t('language.options.english'),
},
{
key: RecordingLanguage.GERMAN,
label: t('language.options.german'),
},
{
key: RecordingLanguage.DUTCH,
label: t('language.options.dutch'),
},
{
key: RecordingLanguage.AUTOMATIC,
label: t('language.options.auto'),
@@ -5,5 +5,4 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
ACCESSIBILITY = 'accessibility',
}
@@ -2,14 +2,10 @@ import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { useStartSubtitle } from '../api/startSubtitle'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useRoomContext } from '@livekit/components-react'
import { useEffect } from 'react'
import { RoomEvent } from 'livekit-client'
export const useSubtitles = () => {
const layoutSnap = useSnapshot(layoutStore)
const room = useRoomContext()
const apiRoomData = useRoomData()
const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle()
@@ -24,18 +20,6 @@ export const useSubtitles = () => {
layoutStore.showSubtitles = !layoutSnap.showSubtitles
}
useEffect(() => {
if (!room) return
const closeSubtitles = () => {
layoutStore.showSubtitles = false
}
room.on(RoomEvent.Disconnected, closeSubtitles)
return () => {
room.off(RoomEvent.Disconnected, closeSubtitles)
}
}, [room])
return {
areSubtitlesOpen: layoutSnap.showSubtitles,
toggleSubtitles,
-61
View File
@@ -1,61 +0,0 @@
import { useEffect, useRef } from 'react'
export type RestoreFocusOptions = {
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
onOpened?: () => void
onClosed?: () => void
restoreFocusRaf?: boolean
preventScroll?: boolean
}
/**
* Capture the element that opened a panel/menu (on open transition) and restore focus to it
* when the panel/menu closes.
*/
export function useRestoreFocus(
isOpen: boolean,
options: RestoreFocusOptions = {}
) {
const {
resolveTrigger,
onOpened,
onClosed,
restoreFocusRaf = false,
preventScroll = true,
} = options
const prevIsOpenRef = useRef(false)
const triggerRef = useRef<HTMLElement | null>(null)
useEffect(() => {
const wasOpen = prevIsOpenRef.current
// Just opened
if (!wasOpen && isOpen) {
const activeEl = document.activeElement as HTMLElement | null
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
onOpened?.()
}
// Just closed
if (wasOpen && !isOpen) {
const trigger = triggerRef.current
if (trigger && document.contains(trigger)) {
const focus = () => trigger.focus({ preventScroll })
if (restoreFocusRaf) requestAnimationFrame(focus)
else focus()
}
triggerRef.current = null
onClosed?.()
}
prevIsOpenRef.current = isOpen
}, [
isOpen,
onClosed,
onOpened,
preventScroll,
resolveTrigger,
restoreFocusRaf,
])
}
+6 -3
View File
@@ -44,15 +44,18 @@
},
"slide1": {
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!",
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere.",
"imgAlt": "Illustration einer benutzerfreundlichen und barrierefreien Kollaborationsplattform"
},
"slide2": {
"title": "Gruppenanrufe ohne Einschränkungen",
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße."
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße.",
"imgAlt": "Bild eines virtuellen Meetings mit mehreren nahtlos zusammenarbeitenden Teilnehmenden"
},
"slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!"
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!",
"imgAlt": "Illustration KI-gestützter Notizen in einem virtuellen Meeting"
}
}
}
+9 -45
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "Sie haben das Meeting verlassen",
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten",
"participantRemoved": "Sie wurden von einem Administrator aus dem Anruf entfernt"
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten"
},
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
@@ -86,7 +85,6 @@
"heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"closeDialog": "Dialogfenster schließen",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
@@ -193,9 +191,7 @@
"leave": "Verlassen",
"participants": {
"open": "Alle ausblenden",
"closed": "Alle anzeigen",
"count_one": "{{count}} Teilnehmer",
"count_other": "{{count}} Teilnehmer"
"closed": "Alle anzeigen"
},
"tools": {
"open": "Weitere Tools ausblenden",
@@ -209,18 +205,7 @@
"reactions": {
"button": "Reaktion senden",
"send": "Reaktion {{emoji}} senden",
"announce": "{{name}} : {{emoji}}",
"you": "Sie",
"emojis": {
"thumbs-up": "Daumen hoch",
"thumbs-down": "Daumen runter",
"clapping-hands": "Klatschende Hände",
"red-heart": "rotes Herz",
"face-with-tears-of-joy": "Gesicht mit Freudentränen",
"face-with-open-mouth": "überraschter Gesichtsausdruck",
"party-popper": "Party-Popper",
"folded-hands": "gefaltete Hände"
}
"you": "Sie"
}
},
"options": {
@@ -248,34 +233,15 @@
"clear": "Effekt deaktivieren",
"blur": {
"title": "Hintergrundunschärfe",
"status": {
"none": "Hintergrund scharf.",
"light": "Hintergrund leicht verschwommen.",
"strong": "Hintergrund stark verschwommen."
},
"light": {
"apply": "Hintergrund leicht verwischen",
"clear": "Unschärfe deaktivieren"
},
"normal": {
"apply": "Hintergrund verwischen",
"clear": "Unschärfe deaktivieren"
}
"light": "Leichte Unschärfe",
"normal": "Unschärfe",
"apply": "Unschärfe aktivieren",
"clear": "Unschärfe deaktivieren"
},
"virtual": {
"title": "Virtueller Hintergrund",
"selectedLabel": "Hintergrund angewendet:",
"apply": "Ersetze deinen Hintergrund:",
"descriptions": {
"0": "Gerilltes Holzmöbel",
"1": "Besprechungsraum",
"2": "Loft mit schwarzer Glaswand",
"3": "Esszimmer",
"4": "Holzregale",
"5": "Holztreppe",
"6": "Graue Bibliothek",
"7": "Kaffeetheke"
}
"apply": "Virtuellen Hintergrund aktivieren",
"clear": "Virtuellen Hintergrund deaktivieren"
},
"faceLandmarks": {
"title": "Visuelle Effekte",
@@ -290,8 +256,6 @@
}
},
"sidePanel": {
"ariaLabel": "Seitenleiste",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": {
"participants": "Teilnehmer",
"effects": "Effekte",
@@ -75,8 +75,6 @@
"options": {
"french": "Französisch (fr)",
"english": "Englisch (en)",
"dutch": "Niederländisch (nl)",
"german": "Deutsch (de)",
"auto": "Automatisch"
}
}
@@ -70,42 +70,19 @@
},
"article7": {
"title": "7. Verpflichtungen und Verantwortlichkeiten von DINUM",
"content": "DINUM ist für die allgemeine Verwaltung des Dienstes verantwortlich. Sie stellt den Bediensteten alle erforderlichen Informationen zur Verfügung, um die Nutzung zu erleichtern. Sie bietet technische und funktionale Unterstützung, um den ordnungsgemäßen Betrieb des Dienstes sicherzustellen. Sie informiert die Nutzer auf jede zumutbare Weise über jede Schwierigkeit, die diesen ordnungsgemäßen Betrieb beeinträchtigen könnte.",
"sections": {
"section1": {
"title": "7.1. Verfügbarkeitsniveau",
"title": "7.1. Sicherheit und Plattformzugang",
"content": "DINUM verpflichtet sich, Visio zu sichern, insbesondere durch alle notwendigen Maßnahmen zur Gewährleistung der Sicherheit und Vertraulichkeit der bereitgestellten Informationen.",
"paragraphs": [
"Der Dienst ist 24/7 verfügbar, vorbehaltlich Wartungszeiten.",
"DINUM verfolgt ein jährliches Verfügbarkeitsziel von 99,5 % (ohne geplante Ausfälle). Bei einem Vorfall oder einer Wartung strebt sie eine Wiederherstellungszeit von 72 Stunden außerhalb der Geschäftszeiten an.",
"Im Rahmen der Wartung der Ausführungsumgebung des Dienstes behält sich DINUM das Recht vor, den Betrieb des Dienstes vorübergehend auszusetzen. Sie informiert die Nutzer mindestens 48 Stunden im Voraus. In dringenden Fällen kann diese Aussetzung ohne Vorankündigung erfolgen.",
"Diese außergewöhnlichen Stillstände können beispielsweise für Maßnahmen zur Datenverwaltung im Back Office, Produktionsbereitstellungen oder Architekturänderungen erforderlich sein.",
"Eine Nichtverfügbarkeit des Dienstes begründet keinerlei Anspruch auf Entschädigung."
"DINUM verpflichtet sich, die notwendigen und angemessenen Mittel bereitzustellen, um einen kontinuierlichen Zugang zu Visio zu gewährleisten.",
"DINUM behält sich das Recht vor, den Dienst aus Wartungsgründen oder aus anderen als notwendig erachteten Gründen ohne Vorankündigung zu ändern, zu modifizieren oder auszusetzen.",
"DINUM behält sich das Recht vor, ein Nutzerkonto des Dienstes zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger strafrechtlicher und zivilrechtlicher Maßnahmen, die gegen den Nutzer ergriffen werden könnten."
]
},
"section2": {
"title": "7.2. Sicherheit des Dienstes",
"paragraphs": [
"DINUM ergreift alle erforderlichen Vorsichtsmaßnahmen, um die Sicherheit der Plattform und der im Rahmen des Dienstes eingesetzten Werkzeuge zu gewährleisten, insbesondere in Bezug auf den Zugang zum Dienst, die Verwaltung der Nutzerkonten und die Verarbeitung der erhobenen Daten."
]
},
"section3": {
"title": "7.3. Support-Management",
"paragraphs": [
"DINUM leistet First-Level-Support für die Nutzer, ausschließlich zu den technischen Teilen des Dienstes.",
"Dieser Support ist über unser Online-Kontaktformular erreichbar.",
"Die angestrebte Antwortzeit auf Anfragen beträgt zwei Werktage ab Absenden; dieser Zeitraum kann je nach Komplexität und Anzahl der zu bearbeitenden Anfragen variieren."
]
},
"section4": {
"title": "7.4. Kontrolle der Dienstenutzung",
"paragraphs": [
"DINUM behält sich das Recht vor, ein Agentenkonto zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger straf- und zivilrechtlicher Maßnahmen gegen den betreffenden Agenten.",
"Die Sperrung oder Aufhebung eines oder mehrerer Konten begründet keinen Anspruch auf irgendeine Entschädigung."
]
},
"section5": {
"title": "7.5. Open Source und Lizenzen",
"content": "Der Quellcode des Dienstes ist frei und hier verfügbar: https://github.com/suitenumerique/meet. Die von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
"title": "7.2. Open Source und Lizenzen",
"content": "Der Quellcode von Visio ist frei und hier verfügbar: https://github.com/suitenumerique/meet\nDie von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
}
}
},
+6 -3
View File
@@ -44,15 +44,18 @@
},
"slide1": {
"title": "Make the switch to simplicity. Try us now!",
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more."
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
},
"slide2": {
"title": "Host group calls without limits",
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size."
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.",
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
},
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
}
}
}
+15 -51
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device",
"participantRemoved": "You have been removed from the meeting by an administrator"
"duplicateIdentity": "You have joined the meeting from another device"
},
"home": "Return to home",
"back": "Rejoin the meeting"
@@ -16,7 +15,7 @@
"audio": "Audio settings",
"video": "Video settings"
},
"effects": "Backgrounds and Effects",
"effects": "Effects",
"videoinput": {
"choose": "Select camera",
"permissionsNeeded": "Select camera - permission needed",
@@ -39,7 +38,7 @@
},
"join": {
"effects": {
"description": "Apply backgrounds and effects",
"description": "Apply effects",
"title": "Effects",
"subTitle": "Configure your camera's effects."
},
@@ -86,7 +85,6 @@
"heading": "Your meeting is ready",
"description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting.",
"closeDialog": "Close dialog",
"phone": {
"call": "Call:",
"pinCode": "Code:"
@@ -193,9 +191,7 @@
"leave": "Leave",
"participants": {
"open": "Hide everyone",
"closed": "See everyone",
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
"closed": "See everyone"
},
"tools": {
"open": "Hide more tools",
@@ -209,18 +205,7 @@
"reactions": {
"button": "Send reaction",
"send": "Send reaction {{emoji}}",
"announce": "{{name}} : {{emoji}}",
"you": "you",
"emojis": {
"thumbs-up": "thumbs up",
"thumbs-down": "thumbs down",
"clapping-hands": "clapping hands",
"red-heart": "red heart",
"face-with-tears-of-joy": "face with tears of joy",
"face-with-open-mouth": "surprised face",
"party-popper": "party popper",
"folded-hands": "folded hands"
}
"you": "you"
}
},
"options": {
@@ -231,7 +216,7 @@
"screenRecording": "Screen recording",
"transcript": "Transcription",
"username": "Update Your Name",
"effects": "Backgrounds and Effects",
"effects": "Apply effects",
"switchCamera": "Switch camera",
"fullscreen": {
"enter": "Fullscreen",
@@ -248,34 +233,15 @@
"clear": "Disable effect",
"blur": {
"title": "Background blur",
"status": {
"none": "Background clear.",
"light": "Background slightly blurred.",
"strong": "Background strongly blurred."
},
"light": {
"apply": "Slightly blur your background",
"clear": "Disable blur"
},
"normal": {
"apply": "Blur your background",
"clear": "Disable blur"
}
"light": "Light blur",
"normal": "Blur",
"apply": "Enable blur",
"clear": "Disable blur"
},
"virtual": {
"title": "Virtual background",
"selectedLabel": "Background applied:",
"apply": "Replace your background:",
"descriptions": {
"0": "Fluted wooden furniture",
"1": "Meeting room",
"2": "Loft with black glass partition",
"3": "Dining room",
"4": "Wooden shelves",
"5": "Wooden staircase",
"6": "Gray library",
"7": "Coffee counter"
}
"apply": "Enable virtual background",
"clear": "Disable virtual background"
},
"faceLandmarks": {
"title": "Visual Effects",
@@ -290,11 +256,9 @@
}
},
"sidePanel": {
"ariaLabel": "Sidepanel",
"backToTools": "Back to meeting tools",
"heading": {
"participants": "Participants",
"effects": "Backgrounds and Effects",
"effects": "Effects",
"chat": "Messages in the chat",
"transcript": "Transcribe",
"screenRecording": "Record",
@@ -304,7 +268,7 @@
},
"content": {
"participants": "participants",
"effects": "Backgrounds and Effects",
"effects": "effects",
"chat": "messages",
"transcript": "transcribe",
"screenRecording": "record",
@@ -579,7 +543,7 @@
"enable": "Pin",
"disable": "Unpin"
},
"effects": "Apply backgrounds and effects",
"effects": "Apply visual effects",
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
},
@@ -75,8 +75,6 @@
"options": {
"french": "French (fr)",
"english": "English (en)",
"dutch": "Dutch (nl)",
"german": "German (de)",
"auto": "Automatic"
}
}
@@ -108,18 +106,12 @@
"label": "Language"
},
"settingsButtonLabel": "Settings",
"accessibility": {
"announceReactions": {
"label": "Announce reactions aloud"
}
},
"tabs": {
"account": "Profile",
"audio": "Audio",
"video": "Video",
"general": "General",
"notifications": "Notifications",
"accessibility": "Accessibility",
"transcription": "Transcription"
}
}
@@ -70,42 +70,19 @@
},
"article7": {
"title": "7. DINUM Commitments and Responsibilities",
"content": "DINUM is responsible for the overall administration of the service. It provides agents with all necessary information to facilitate its use. It offers technical and functional assistance to ensure the proper operation of the service. It informs users by any reasonable means of any difficulty that may affect this proper operation.",
"sections": {
"section1": {
"title": "7.1. Service Availability",
"title": "7.1. Security and Platform Access",
"content": "DINUM is committed to securing Visio, notably by taking all necessary measures to ensure the security and confidentiality of the information provided.",
"paragraphs": [
"The service is available 24/7, except during maintenance windows.",
"DINUM targets an annual service availability of 99.5%, excluding planned outages. In case of an incident or maintenance, it aims for a recovery time of 72 hours outside business hours.",
"As part of maintaining the service runtime environment, DINUM reserves the right to temporarily suspend the operation of the service. It informs users at least 48 hours in advance. In emergencies, this suspension may occur without notice.",
"These exceptional stoppages may be necessary, for example, for back-office data management operations, production deployments, or architecture changes.",
"Service unavailability does not give rise to any compensation of any kind."
"DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
"DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
"DINUM reserves the right to suspend or delete a user account of the service that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the user."
]
},
"section2": {
"title": "7.2. Service Security",
"paragraphs": [
"DINUM takes all necessary precautions to preserve the security of the platform and the tools used within the service, particularly regarding access to the service, management of user accounts, and processing of collected data."
]
},
"section3": {
"title": "7.3. Support Management",
"paragraphs": [
"DINUM provides first-level support to users, exclusively on the technical parts of the service.",
"This support can be reached via our online contact form.",
"The target response time to requests is two business days from submission; this timeframe may vary depending on the complexity and number of requests to be processed."
]
},
"section4": {
"title": "7.4. Monitoring Service Use",
"paragraphs": [
"DINUM reserves the right to suspend or delete an agent account that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the agent concerned.",
"The suspension or revocation of one or more accounts does not entitle the user to any compensation of any kind."
]
},
"section5": {
"title": "7.5. Open Source and Licenses",
"content": "The service source code is free and available here: https://github.com/suitenumerique/meet. The content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
"title": "7.2 Open Source and Licenses",
"content": "The source code of Visio is free and available here: https://github.com/suitenumerique/meet\nThe content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
}
}
},
+6 -3
View File
@@ -44,15 +44,18 @@
},
"slide1": {
"title": "Passez à la simplicité. Essayez-nous dès maintenant !",
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore."
"body": "Découvrez une solution intuitive et accessible, conçue pour tous les agents publics et leurs partenaires, et bien plus encore.",
"imgAlt": "Illustration d'une plateforme de collaboration simple et accessible"
},
"slide2": {
"title": "Organisez des appels de groupe sans limite",
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre."
"body": "Réunions sans limite de temps, avec une communication fluide et de haute qualité, quel que soit le nombre.",
"imgAlt": "Image d'une réunion virtuelle avec plusieurs participants collaborant efficacement"
},
"slide3": {
"title": "Transformez vos réunions avec l'IA",
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !"
"body": "Obtenez des transcriptions précises et actionnables, pour booster votre productivité. Fonctionnalité en beta, essayez-la maintenant !",
"imgAlt": "Illustration de prise de notes assistée par l'IA dans une réunion virtuelle"
}
}
}
+16 -52
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "Vous avez quitté la réunion",
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil",
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur"
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil"
},
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
@@ -16,7 +15,7 @@
"audio": "Paramètres audio",
"video": "Paramètres video"
},
"effects": "Arrière-plans et effets",
"effects": "Effets d'arrière-plan",
"videoinput": {
"choose": "Choisir la webcam",
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
@@ -40,8 +39,8 @@
"join": {
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Arrière-plans et effets",
"title": "Arrière-plans et effets",
"description": "Effets d'arrière-plan",
"title": "Effets",
"subTitle": "Paramétrez les effets de votre caméra."
},
"joinLabel": "Rejoindre",
@@ -86,7 +85,6 @@
"heading": "Votre réunion est prête",
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
"closeDialog": "Fermer la fenêtre modale",
"phone": {
"call": "Appelez le :",
"pinCode": "Code :"
@@ -193,9 +191,7 @@
"leave": "Quitter",
"participants": {
"open": "Masquer les participants",
"closed": "Afficher les participants",
"count_one": "{{count}} participant",
"count_other": "{{count}} participants"
"closed": "Afficher les participants"
},
"tools": {
"open": "Masquer les outils de réunion",
@@ -209,18 +205,7 @@
"reactions": {
"button": "Envoyer une réaction",
"send": "Envoyer la réaction {{emoji}}",
"announce": "{{name}} : {{emoji}}",
"you": "vous",
"emojis": {
"thumbs-up": "pouce levé",
"thumbs-down": "pouce baissé",
"clapping-hands": "applaudissements",
"red-heart": "cœur",
"face-with-tears-of-joy": "visage qui rit",
"face-with-open-mouth": "visage étonné",
"party-popper": "confettis",
"folded-hands": "mains jointes"
}
"you": "vous"
}
},
"options": {
@@ -231,7 +216,7 @@
"screenRecording": "Enregistrement",
"transcript": "Transcription",
"username": "Choisir votre nom",
"effects": "Arrière-plans et effets",
"effects": "Effets d'arrière-plan",
"switchCamera": "Changer de caméra",
"fullscreen": {
"enter": "Plein écran",
@@ -245,37 +230,18 @@
"cameraDisabled": "Votre caméra est désactivée.",
"notAvailable": "Les effets vidéo seront bientôt disponibles sur votre navigateur. Nous y travaillons ! En attendant, vous pouvez utiliser Google Chrome pour de meilleures performances ou Firefox :(",
"heading": "Flou",
"clear": "Désactiver l'effet",
"clear": "Désactiver l'effect",
"blur": {
"title": "Flou d'arrière-plan",
"status": {
"none": "Arrière-plan net.",
"light": "Arrière-plan légèrement flouté.",
"strong": "Arrière-plan fortement flouté."
},
"light": {
"apply": "Flouter l'arrière-plan",
"clear": "Désactiver le flou"
},
"normal": {
"apply": "Flouter encore plus l'arrière-plan",
"clear": "Désactiver le flou"
}
"light": "Flou léger",
"normal": "Flou",
"apply": "Activer le flou",
"clear": "Désactiver le flou"
},
"virtual": {
"title": "Arrière-plan virtuel",
"selectedLabel": "Arrière-plan appliqué :",
"apply": "Remplacer votre arrière plan :",
"descriptions": {
"0": "Meuble cannelé en bois",
"1": "Salle de réunion",
"2": "Loft avec verrière noire",
"3": "Salle à manger",
"4": "Étagères en bois",
"5": "Escalier en bois",
"6": "Bibliothèque grise",
"7": "Comptoir de café"
}
"apply": "Activer l'arrière-plan virtuel",
"clear": "Désactiver l'arrière-plan virtuel"
},
"faceLandmarks": {
"title": "Effets visuels",
@@ -290,11 +256,9 @@
}
},
"sidePanel": {
"ariaLabel": "Panneau latéral",
"backToTools": "Retour aux outils de réunion",
"heading": {
"participants": "Participants",
"effects": "Arrière-plans et effets",
"effects": "Effets",
"chat": "Messages dans l'appel",
"transcript": "Transcrire",
"screenRecording": "Enregistrer",
@@ -579,7 +543,7 @@
"enable": "Épingler",
"disable": "Annuler l'épinglage"
},
"effects": "Arrière-plans et effets",
"effects": "Effets d'arrière-plan",
"muteParticipant": "Couper le micro de {{name}}",
"fullScreen": "Plein écran"
},
@@ -75,8 +75,6 @@
"options": {
"french": "Français (fr)",
"english": "Anglais (en)",
"dutch": "Hollandais (nl)",
"german": "Allemand (de)",
"auto": "Automatique"
}
}
@@ -108,18 +106,12 @@
"label": "Langue de l'application"
},
"settingsButtonLabel": "Paramètres",
"accessibility": {
"announceReactions": {
"label": "Vocaliser les réactions"
}
},
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Vidéo",
"general": "Général",
"notifications": "Notifications",
"accessibility": "Accessibilité",
"transcription": "Transcription"
}
}
@@ -16,7 +16,7 @@
},
"article4": {
"title": "4. Utilisation",
"content": "L'utilisation du service est libre et gratuite, pour administrer un salon de visioconférence, il est nécessaire de se connecter par ProConnect."
"content": "L'utilisation du service est libre et gratuite, pour administration un salon de visioconférence, il est nécessaire de se connecter par ProConnect."
},
"article5": {
"title": "5. Fonctionnalités",
@@ -70,42 +70,19 @@
},
"article7": {
"title": "7. Engagements et responsabilités de la DINUM",
"content": "La DINUM est responsable de ladministration générale du service. Elle transmet aux agents toutes les informations nécessaires pour faciliter son utilisation. Elle propose une assistance technique et fonctionnelle en vue dassurer le bon fonctionnement du service. Elle informe par tout moyen raisonnable les utilisateurs de toute difficulté de nature à affecter ce bon fonctionnement.",
"sections": {
"section1": {
"title": "7.1. Niveau de disponibilité",
"title": "7.1. Sécurité et accès à la plateforme",
"content": "La DINUM s'engage à la sécurisation de Visio, notamment en prenant toutes les mesures nécessaires permettant de garantir la sécurité et la confidentialité des informations fournies.",
"paragraphs": [
"La plage douverture du Service est 24h/24 7j/7, hors période dindisponibilité pour maintenance.",
"La DINUM poursuit un objectif de disponibilité annuelle du service de 99,5%, hors indisponibilités planifiées. En cas dincident ou de maintenance, elle vise un délai de rétablissement de 72h en heures non ouvrables.",
"Dans le cadre dune maintenance de lenvironnement dexécution du service, La DINUM se réserve le droit de suspendre temporairement le fonctionnement du service. Elle tient informée les utilisateurs au minimum 48h à lavance. En cas durgence, cette suspension peut intervenir sans préavis.",
"Ces arrêts exceptionnels peuvent être rendus nécessaires par exemple pour des opérations de gestion des données en back office, des opérations de mise en production ou des changements darchitecture.",
"Lindisponibilité du service nouvre droit à aucune compensation de quelque nature que ce soit."
"La DINUM s'engage à fournir les moyens nécessaires et raisonnables pour assurer un accès continu à Visio.",
"La DINUM se réserve le droit de faire évoluer, de modifier ou de suspendre, sans préavis, le service pour des raisons de maintenance ou pour tout autre motif jugé nécessaire.",
"La DINUM se réserve le droit de suspendre ou supprimer un compte utilisateur ou utilisatrice du service qui aurait méconnu les présentes modalités d'utilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à l'encontre de l'utilisateur ou l'utilisatrice."
]
},
"section2": {
"title": "7.2. Sécurité du service",
"paragraphs": [
"La DINUM prend toutes les précautions utiles pour préserver la sécurité de la plateforme et des outils mis en œuvre dans le cadre du service, notamment sagissant de laccès au service, de la gestion des comptes utilisateurs et du traitement des données collectées."
]
},
"section3": {
"title": "7.3. Gestion du support",
"paragraphs": [
"La DINUM assure le support de premier niveau auprès des utilisateurs, exclusivement sur les parties techniques du service.",
"Ce support est joignable sur notre formulaire de contact en ligne.",
"Lobjectif de réponse aux sollicitations est de deux jours ouvrés à compter de lenvoi du courriel, ce délai peut varier selon la complexité et le nombre de demandes à traiter."
]
},
"section4": {
"title": "7.4. Contrôle de lutilisation du service",
"paragraphs": [
"La DINUM se réserve le droit de suspendre ou supprimer un compte agent qui aurait méconnu les présentes modalités dutilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à lencontre de lagent concerné.",
"La suspension ou la révocation dun ou plusieurs comptes ne donne lieu à aucune compensation daucune sorte."
]
},
"section5": {
"title": "7.5. Open Source et Licences",
"content": "Le code source du service est libre et disponible ici : https://github.com/suitenumerique/meet. Les contenus proposés par la DINUM sont sous Licence Ouverte, à lexception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
"title": "7.2 Open Source et Licences",
"content": "Le code source de Visio est libre et disponible ici : https://github.com/suitenumerique/meet\nLes contenus proposés par la DINUM sont sous Licence Ouverte, à l'exception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
}
}
},
+6 -3
View File
@@ -44,15 +44,18 @@
},
"slide1": {
"title": "Stap over op eenvoud. Probeer ons nu!",
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer."
"body": "Ontdek een intuïtieve en toegankelijke oplossing, ontworpen voor alle openbare partijen, hun partners en nog veel meer.",
"imgAlt": "Illustratie van een gebruiksvriendelijk en toegankelijk samenwerkingsplatform"
},
"slide2": {
"title": "Houdt groepsgesprekken zonder limieten",
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte."
"body": "Vergaderingen van onbeperkte lengte, met soepele en hoogwaardige communicatie, ongeacht de groepsgrootte.",
"imgAlt": "Afbeelding van een virtuele ontmoeting met meerdere deelnemers die naadloos samenwerken"
},
"slide3": {
"title": "Transformeer uw vergaderingen met AI",
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!"
"body": "Krijg nauwkeurige en bruikbare transcripties om uw productiviteit te stimuleren. Deze mogelijkheid is in bèta, probeer het nu!",
"imgAlt": "Illustratie van AI-aangedreven notitie in een virtuele vergadering"
}
}
}
+9 -45
View File
@@ -2,8 +2,7 @@
"feedback": {
"heading": {
"normal": "Je hebt de vergadering verlaten",
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend",
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd"
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend"
},
"home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan"
@@ -86,7 +85,6 @@
"heading": "Uw vergadering is klaar",
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
"closeDialog": "Sluit het dialoogvenster",
"phone": {
"call": "Bel:",
"pinCode": "Code:"
@@ -193,9 +191,7 @@
"leave": "Vertrekken",
"participants": {
"open": "Verberg iedereen",
"closed": "Toon iedereen",
"count_one": "{{count}} deelnemer",
"count_other": "{{count}} deelnemers"
"closed": "Toon iedereen"
},
"tools": {
"open": "Meer tools verbergen",
@@ -209,18 +205,7 @@
"reactions": {
"button": "Stuur reactie",
"send": "Stuur reactie {{emoji}}",
"announce": "{{name}} : {{emoji}}",
"you": "U",
"emojis": {
"thumbs-up": "duim omhoog",
"thumbs-down": "duim omlaag",
"clapping-hands": "applaudisserende handen",
"red-heart": "rood hart",
"face-with-tears-of-joy": "gezicht met tranen van vreugde",
"face-with-open-mouth": "verrast gezicht",
"party-popper": "feestknaller",
"folded-hands": "gevouwen handen"
}
"you": "U"
}
},
"options": {
@@ -248,34 +233,15 @@
"clear": "Effect uitschakelen",
"blur": {
"title": "Achtergrondvervaging",
"status": {
"none": "Achtergrond scherp.",
"light": "Achtergrond licht vervaagd.",
"strong": "Achtergrond sterk vervaagd."
},
"light": {
"apply": "Vervaag je achtergrond lichtjes",
"clear": "Vervaging uitschakelen"
},
"normal": {
"apply": "Vervaag je achtergrond",
"clear": "Vervaging uitschakelen"
}
"light": "Lichte vervaging",
"normal": "Vervaging",
"apply": "Vervaging inschakelen",
"clear": "Vervaging uitschakelen"
},
"virtual": {
"title": "Virtuele achtergrond",
"selectedLabel": "Achtergrond toegepast:",
"apply": "Vervang je achtergrond:",
"descriptions": {
"0": "Geprofileerd houten meubel",
"1": "Vergaderruimte",
"2": "Loft met zwarte glaswand",
"3": "Eetkamer",
"4": "Houten planken",
"5": "Houten trap",
"6": "Grijze bibliotheek",
"7": "Koffiebar"
}
"apply": "Virtuele achtergrond inschakelen",
"clear": "Virtuele achtergrond uitschakelen"
},
"faceLandmarks": {
"title": "Visuele effecten",
@@ -290,8 +256,6 @@
}
},
"sidePanel": {
"ariaLabel": "Zijbalk",
"backToTools": "Terug naar vergadertools",
"heading": {
"participants": "Deelnemers",
"effects": "Effecten",
@@ -75,8 +75,6 @@
"options": {
"french": "Frans (fr)",
"english": "Engels (en)",
"dutch": "Nederlands (nl)",
"german": "Duits (de)",
"auto": "Automatisch"
}
}
@@ -70,42 +70,19 @@
},
"article7": {
"title": "7. DINUM Commitments and Responsibilities",
"content": "De DINUM is verantwoordelijk voor de algemene administratie van de dienst. Zij verstrekt de medewerkers alle nodige informatie om het gebruik te vergemakkelijken. Zij biedt technische en functionele ondersteuning om de goede werking van de dienst te verzekeren. Zij informeert de gebruikers met elk redelijk middel over moeilijkheden die deze goede werking kunnen beïnvloeden.",
"sections": {
"section1": {
"title": "7.1. Beschikbaarheidsniveau",
"title": "7.1. Security and Platform Access",
"content": "DINUM is committed to securing Visio, notably by taking all necessary measures to ensure the security and confidentiality of the information provided.",
"paragraphs": [
"De dienstverlening is 24u/24 en 7d/7 beschikbaar, behalve tijdens onderhoudsonderbrekingen.",
"DINUM streeft naar een jaarlijkse beschikbaarheid van 99,5% van de dienst, buiten geplande onbeschikbaarheden. Bij een incident of onderhoud mikt zij op een hersteltermijn van 72 uur buiten kantooruren.",
"In het kader van onderhoud van de uitvoeringsomgeving van de dienst behoudt DINUM zich het recht voor de werking tijdelijk op te schorten. Zij informeert de gebruikers minstens 48 uur vooraf. In noodgevallen kan deze opschorting zonder voorafgaande kennisgeving plaatsvinden.",
"Deze uitzonderlijke stilstanden kunnen bijvoorbeeld nodig zijn voor backoffice-gegevensbeheer, inproductiestellingen of architectuurwijzigingen.",
"Onbeschikbaarheid van de dienst geeft geen recht op enige vorm van compensatie."
"DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
"DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
"DINUM reserves the right to suspend or delete a user account of the service that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the user."
]
},
"section2": {
"title": "7.2. Beveiliging van de dienst",
"paragraphs": [
"DINUM neemt alle nodige voorzorgsmaatregelen om de veiligheid van het platform en de in het kader van de dienst gebruikte hulpmiddelen te bewaren, met name wat betreft de toegang tot de dienst, het beheer van gebruikersaccounts en de verwerking van verzamelde gegevens."
]
},
"section3": {
"title": "7.3. Supportbeheer",
"paragraphs": [
"DINUM verzorgt eerstelijns support voor de gebruikers, uitsluitend over de technische onderdelen van de dienst.",
"Deze support is bereikbaar via ons online contactformulier.",
"De streefantwoordtermijn op verzoeken is twee werkdagen vanaf de verzending; deze termijn kan variëren naargelang de complexiteit en het aantal te behandelen aanvragen."
]
},
"section4": {
"title": "7.4. Controle van het gebruik van de dienst",
"paragraphs": [
"DINUM behoudt zich het recht voor om een agentaccount te schorsen of te verwijderen dat deze gebruiksvoorwaarden heeft geschonden, onverminderd eventuele strafrechtelijke en civielrechtelijke stappen tegen de betrokken agent.",
"De schorsing of intrekking van één of meer accounts geeft geen recht op enige vorm van compensatie."
]
},
"section5": {
"title": "7.5. Open Source en licenties",
"content": "De broncode van de dienst is vrij beschikbaar op: https://github.com/suitenumerique/meet. De inhoud die door DINUM wordt aangeboden valt onder een Open License, met uitzondering van logo's en iconografische en fotografische voorstellingen die onder hun eigen licenties kunnen vallen."
"title": "7.2 Open Source and Licenses",
"content": "The source code of Visio is free and available here: https://github.com/suitenumerique/meet\nThe content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
}
}
},
+22 -21
View File
@@ -5,7 +5,7 @@ import {
import { type RecipeVariantProps } from '@/styled-system/css'
import { buttonRecipe, type ButtonRecipe } from './buttonRecipe'
import { TooltipWrapper, type TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode, forwardRef } from 'react'
import { ReactNode } from 'react'
import { Loader } from './Loader'
export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
@@ -17,24 +17,25 @@ export type ButtonProps = RecipeVariantProps<ButtonRecipe> &
icon?: ReactNode
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ tooltip, tooltipType = 'instant', ...props }, ref) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
export const Button = ({
tooltip,
tooltipType = 'instant',
...props
}: ButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
ref={ref}
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
className={[buttonRecipe(variantProps), className].join(' ')}
{...(remainingComponentProps as RACButtonsProps)}
>
{!props.loading && props.icon}
{props.loading && <Loader />}
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</RACButton>
</TooltipWrapper>
)
}
-71
View File
@@ -1,71 +0,0 @@
import { cva, RecipeVariantProps } from '@/styled-system/css'
import { ComponentPropsWithoutRef } from 'react'
const iconRecipe = cva({
base: {
fontWeight: 'normal',
fontStyle: 'normal',
display: 'inline-block',
lineHeight: 1,
textTransform: 'none',
letterSpacing: 'normal',
wordWrap: 'normal',
whiteSpace: 'nowrap',
direction: 'ltr',
},
variants: {
type: {
icons: {
fontFamily: 'Material Icons Outlined',
webkitFontSmoothing: 'antialiased',
mozOsxFontSmoothing: 'grayscale',
textRendering: 'optimizeLegibility',
fontFeatureSettings: '"liga"',
},
symbols: {
fontFamily: 'Material Symbols Outlined Variable',
},
},
size: {
sm: {
fontSize: '18px',
},
md: {
fontSize: '24px',
},
lg: {
fontSize: '32px',
},
xl: {
fontSize: '40px',
},
},
},
defaultVariants: {
type: 'icons',
size: 'md',
},
})
export type IconRecipeProps = RecipeVariantProps<typeof iconRecipe>
export type IconProps = IconRecipeProps &
ComponentPropsWithoutRef<'span'> & {
name: string
}
export const Icon = ({ name, ...props }: IconProps) => {
const [variantProps, componentProps] = iconRecipe.splitVariantProps(props)
const { className, ...remainingComponentProps } = componentProps
return (
<span
translate="no"
aria-hidden="true"
className={[iconRecipe(variantProps), className].join(' ')}
{...remainingComponentProps}
>
{name}
</span>
)
}
+21 -22
View File
@@ -1,10 +1,10 @@
import { forwardRef, ReactNode } from 'react'
import {
ToggleButton as RACToggleButton,
ToggleButtonProps as RACToggleButtonProps,
} from 'react-aria-components'
import { type ButtonRecipeProps, buttonRecipe } from './buttonRecipe'
import { TooltipWrapper, TooltipWrapperProps } from './TooltipWrapper'
import { ReactNode } from 'react'
export type ToggleButtonProps = RACToggleButtonProps &
ButtonRecipeProps &
@@ -16,25 +16,24 @@ export type ToggleButtonProps = RACToggleButtonProps &
/**
* React aria ToggleButton with our button styles, that can take a tooltip if needed
*/
export const ToggleButton = forwardRef<HTMLButtonElement, ToggleButtonProps>(
({ tooltip, tooltipType, ...props }, ref) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
export const ToggleButton = ({
tooltip,
tooltipType,
...props
}: ToggleButtonProps) => {
const [variantProps, componentProps] = buttonRecipe.splitVariantProps(props)
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
ref={ref}
{...componentProps}
className={[buttonRecipe(variantProps), props.className].join(' ')}
>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</RACToggleButton>
</TooltipWrapper>
)
}
)
ToggleButton.displayName = 'ToggleButton'
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACToggleButton
{...componentProps}
className={[buttonRecipe(variantProps), props.className].join(' ')}
>
<>
{componentProps.children as ReactNode}
{props.description && <span>{tooltip}</span>}
</>
</RACToggleButton>
</TooltipWrapper>
)
}
@@ -1,88 +0,0 @@
import { type ReactNode, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { css } from '@/styled-system/css'
export type VisualOnlyTooltipProps = {
children: ReactNode
tooltip: string
}
/**
* Wrapper component that displays a tooltip visually only (not announced by screen readers).
*
* This is necessary because TooltipTrigger from react-aria-components automatically adds
* aria-describedby on the button, which links the tooltip for accessibility.
* Even with aria-hidden="true" on the tooltip, screen readers still announce its content → duplication.
* This CSS wrapper avoids TooltipTrigger → no automatic aria-describedby → no duplication.
*
* Uses a portal to avoid being clipped by parent containers with overflow: hidden.
*/
export const VisualOnlyTooltip = ({
children,
tooltip,
}: VisualOnlyTooltipProps) => {
const wrapperRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
const getPosition = () => {
if (!wrapperRef.current) return null
const rect = wrapperRef.current.getBoundingClientRect()
return {
top: rect.top - 8,
left: rect.left + rect.width / 2,
}
}
const position = getPosition()
const tooltipData = isVisible && position ? { isVisible, position } : null
return (
<>
<div
ref={wrapperRef}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
</div>
{tooltipData &&
createPortal(
<div
aria-hidden="true"
role="presentation"
className={css({
position: 'fixed',
padding: '2px 8px',
backgroundColor: 'primaryDark.100',
color: 'gray.100',
borderRadius: '4px',
fontSize: 14,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 9999,
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
'&::after': {
content: '""',
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
border: '4px solid transparent',
borderTopColor: 'primaryDark.100',
},
})}
style={{
top: `${tooltipData.position.top}px`,
left: `${tooltipData.position.left}px`,
transform: 'translate(-50%, -100%)',
}}
>
{tooltip}
</div>,
document.body
)}
</>
)
}
+1 -2
View File
@@ -112,8 +112,7 @@ export const buttonRecipe = cva({
},
transition: 'box-shadow 0.2s ease-in-out',
'&[data-selected]': {
boxShadow:
'0 0 0 3px token(colors.primary.600) inset, 0 0 0 5px white inset',
boxShadow: 'token(colors.primary.400) 0px 0px 0px 3px inset',
},
'&[data-disabled]': {
backgroundColor: 'greyscale.100',
-1
View File
@@ -30,4 +30,3 @@ export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
export { TextArea } from './TextArea'
export { Switch } from './Switch'
export { Icon } from './Icon'
+5
View File
@@ -43,6 +43,11 @@ export const routes: Record<
feedback: {
name: 'feedback',
path: '/feedback',
to: (params: { duplicateIdentity?: false }) =>
'/feedback' +
(params.duplicateIdentity
? `?duplicateIdentity=${params?.duplicateIdentity}`
: ''),
Component: FeedbackRoute,
},
legalTerms: {
-76
View File
@@ -1,76 +0,0 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { deserializeToProxyMap } from '@/utils/valtio'
type AccessibilityState = {
announceReactions: boolean
}
const DEFAULT_STATE: AccessibilityState = {
announceReactions: false,
}
function getAccessibilityState(): AccessibilityState {
try {
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
if (stored) {
const parsed = JSON.parse(stored)
return {
...DEFAULT_STATE,
...parsed,
announceReactions:
typeof parsed.announceReactions === 'boolean'
? parsed.announceReactions
: DEFAULT_STATE.announceReactions,
}
}
// Legacy migration: if the setting was previously stored in notifications
const legacy = localStorage.getItem(STORAGE_KEYS.NOTIFICATIONS)
if (legacy) {
try {
const parsedLegacy = JSON.parse(legacy, deserializeToProxyMap)
if (typeof parsedLegacy?.announceReactions === 'boolean') {
const migratedState: AccessibilityState = {
...DEFAULT_STATE,
...parsedLegacy,
announceReactions: parsedLegacy.announceReactions,
}
try {
localStorage.setItem(
STORAGE_KEYS.ACCESSIBILITY,
JSON.stringify(migratedState)
)
localStorage.removeItem(STORAGE_KEYS.NOTIFICATIONS)
} catch {
// ignore persistence issues during migration
}
return migratedState
}
} catch {
// ignore legacy parsing issues
}
}
return DEFAULT_STATE
} catch (error: unknown) {
console.error(
'[AccessibilityStore] Failed to parse stored settings:',
error
)
return DEFAULT_STATE
}
}
export const accessibilityStore = proxy<AccessibilityState>(
getAccessibilityState()
)
subscribe(accessibilityStore, () => {
localStorage.setItem(
STORAGE_KEYS.ACCESSIBILITY,
JSON.stringify(accessibilityStore)
)
})
-2
View File
@@ -3,8 +3,6 @@ import { proxy } from 'valtio'
export enum RecordingLanguage {
ENGLISH = 'en',
FRENCH = 'fr',
DUTCH = 'nl',
GERMAN = 'de',
AUTOMATIC = 'auto',
}
-14
View File
@@ -1,14 +0,0 @@
import { css } from '@/styled-system/css'
// Reusable visually hidden style for screen-reader-only content
export const srOnly = css({
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
})
+27
View File
@@ -0,0 +1,27 @@
@import '@fontsource/material-icons-outlined';
.material-icons,
.material-symbols {
font-weight: normal;
font-style: normal;
font-size: 24px;
display: inline-block;
line-height: 1;
text-transform: none;
letter-spacing: normal;
word-wrap: normal;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: 'liga';
}
.material-icons {
font-family: 'Material Icons Outlined';
}
.material-symbols {
font-family: 'Material Symbols Outlined Variable';
}
+1 -45
View File
@@ -1,4 +1,5 @@
@import './livekit.css';
@import './icons.css';
@layer reset, base, tokens, recipes, utilities;
html,
body,
@@ -6,18 +7,6 @@ body,
height: 100%;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
* {
outline: 2px solid transparent;
}
@@ -53,36 +42,3 @@ body:has(.lk-video-conference) #crisp-chatbox > * > div[role='button'] {
transform: translateY(0);
}
}
/* Generated using the Advanced installation guidelines from
* https://fontsource.org/fonts/material-icons-outlined/install
*/
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-display: block;
font-weight: 400;
src: url(@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2)
format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Generated using the Advanced installation guidelines from
* https://fontsource.org/fonts/material-symbols-outlined/install
*/
/* material-symbols-outlined-latin-wght-normal */
@font-face {
font-family: 'Material Symbols Outlined Variable';
font-style: normal;
font-display: block;
font-weight: 100 700;
src: url(@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2)
format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
-1
View File
@@ -4,5 +4,4 @@
export const STORAGE_KEYS = {
NOTIFICATIONS: 'app_notification_settings',
USER_PREFERENCES: 'app_user_preferences',
ACCESSIBILITY: 'app_accessibility_settings',
} as const
@@ -75,11 +75,6 @@ backend:
ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304'
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
ROOM_SUBTITLE_ENABLED: True
EXTERNAL_API_ENABLED: True
APPLICATION_JWT_AUDIENCE: https://meet.127.0.0.1.nip.io/external-api/v1.0/
APPLICATION_JWT_SECRET_KEY: devKeyApplication
APPLICATION_BASE_URL: https://meet.127.0.0.1.nip.io
ADDONS_JWT_SECRET_KEY: devKeyApplicationAddons
migrate:
@@ -155,7 +150,7 @@ summary:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
@@ -192,7 +187,7 @@ celeryTranscribe:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
@@ -230,7 +225,7 @@ celerySummarize:
APP_NAME: summary-microservice
APP_API_TOKEN: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
@@ -272,7 +267,6 @@ agents:
LIVEKIT_API_KEY: {{ $key }}
{{- end }}
{{- end }}
ENABLE_SILERO_VAD: "false"
image:
repository: localhost:5001/meet-agents
-28
View File
@@ -88,20 +88,6 @@ spec:
serviceName: {{ include "meet.backend.fullname" . }}
servicePort: {{ .Values.backend.service.port }}
{{- end }}
- path: /addons/
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.backend.fullname" . }}
port:
number: {{ .Values.backend.service.port }}
{{- else }}
serviceName: {{ include "meet.backend.fullname" . }}
servicePort: {{ .Values.backend.service.port }}
{{- end }}
{{- with .Values.ingress.customBackends }}
{{- toYaml . | nindent 10 }}
{{- end }}
@@ -152,20 +138,6 @@ spec:
serviceName: {{ include "meet.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
- path: /addons/
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.backend.fullname" $ }}
port:
number: {{ $.Values.backend.service.port }}
{{- else }}
serviceName: {{ include "meet.backend.fullname" $ }}
servicePort: {{ $.Values.backend.service.port }}
{{- end }}
{{- with $.Values.ingress.customBackends }}
{{- toYaml . | nindent 10 }}
{{- end }}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.1.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.1.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.3.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.3.0",
"version": "1.1.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.3.0",
"version": "1.1.0",
"author": "",
"license": "ISC",
"description": "",

Some files were not shown because too many files have changed in this diff Show More