Compare commits

..

5 Commits

Author SHA1 Message Date
Cyril 7a7c966b3f ️(frontend) restore focus to trigger button when panel closes
improves keyboard navigation and accessibility consistency

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-18 09:44:59 +01:00
Cyril fadf12626d ️(frontend) fix jump and animation break on panel open with auto-focus
used requestAnimationFrame and preventScroll to preserve smooth transition

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-18 09:44:10 +01:00
Cyril 186723a8c3 (frontend) focus transcript and record buttons on open
move keyboard focus to transcript or recording button when the panel opens.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-18 09:44:10 +01:00
Cyril c6c8a84c41 (frontend) add SR announcements for transcript and recording
announce transcript and record events to sr to provide clear feedback

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-18 09:44:09 +01:00
Cyril 3a42a859f4 (frontend) add sr-only class
add a utility class to hide content visually while keeping it available to sr.

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-18 09:44:08 +01:00
136 changed files with 1819 additions and 3922 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 -45
View File
@@ -8,56 +8,14 @@ and this project adheres to
## [Unreleased]
### 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
## [1.2.0] - 2026-01-05
### Added
- ✨(agent) support Kyutai client for subtitle
- ✨(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
- ✨(backend) enable user creation via email for external integrations
- ✨(summary) add Langfuse observability for LLM API calls
## [1.0.1] - 2025-12-17
### Changed
### Changes
- ♿(frontend) improve accessibility:
- ♿️(frontend) Improve SR and focus for transcript and recording #810
- ♿️(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
@@ -235,7 +235,7 @@ services:
- livekit-egress
livekit-egress:
image: livekit/egress:v1.11.0
image: livekit/egress
environment:
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
volumes:
+1
View File
@@ -277,6 +277,7 @@ These are the environmental options available on meet backend.
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
+4 -3
View File
@@ -7,7 +7,7 @@ info:
#### Authentication Flow
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token/`.
1. Exchange application credentials for a JWT token via `/external-api/v1.0/application/token`.
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
3. Tokens are scoped and allow applications to act on behalf of specific users.
@@ -21,6 +21,7 @@ info:
#### Upcoming Features
* **Create rooms for unknown users from the web app:** Support for generating rooms for users who are not yet registered in the system.
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
@@ -39,7 +40,7 @@ tags:
description: Room management operations
paths:
/application/token/:
/application/token:
post:
tags:
- Authentication
@@ -282,7 +283,7 @@ components:
scheme: bearer
bearerFormat: JWT
description: |
JWT token obtained from the `/application/token/` endpoint.
JWT token obtained from the `/application/token` endpoint.
Include in requests as: `Authorization: Bearer <token>`
schemas:
+1 -1
View File
@@ -63,7 +63,7 @@ RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
# Telephony
ROOM_TELEPHONY_ENABLED=True
+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
-6
View File
@@ -1,11 +1,5 @@
FROM python:3.13-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
WORKDIR /builder
+66 -29
View File
@@ -5,7 +5,6 @@ import logging
import os
from dotenv import load_dotenv
from lasuite.plugins import kyutai
from livekit import api, rtc
from livekit.agents import (
Agent,
@@ -14,15 +13,14 @@ from livekit.agents import (
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.agents import (
room_io as lk_room_io,
)
from livekit.plugins import deepgram, silero
load_dotenv()
@@ -30,26 +28,60 @@ load_dotenv()
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"
# Default Deepgram STT configuration
DEEPGRAM_STT_DEFAULTS = {
"model": "nova-3",
"language": "multi",
}
# Supported parameters for LiveKit's deepgram.STT() in streaming mode
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
# Use language="multi" instead for automatic multilingual support
DEEPGRAM_STT_SUPPORTED_PARAMS = {
"model",
"language",
}
def create_stt_provider():
"""Create STT provider based on environment configuration."""
if STT_PROVIDER == "deepgram":
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
# detect_language is NOT supported for real-time streaming
# Use language="multi" instead for automatic multilingual support
_stt_instance = deepgram.STT(
model=os.getenv("DEEPGRAM_STT_MODEL", "nova-3"),
language=os.getenv("DEEPGRAM_STT_LANGUAGE", "multi"),
)
elif STT_PROVIDER == "kyutai":
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
else:
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
def _build_deepgram_stt_kwargs():
"""Build Deepgram STT kwargs from DEEPGRAM_STT_* environment variables.
return _stt_instance
Only parameters supported by LiveKit's deepgram.STT() are included.
Unsupported parameters are logged as warnings.
"""
stt_kwargs = DEEPGRAM_STT_DEFAULTS.copy()
# Scan environment variables for DEEPGRAM_STT_* pattern
for key, value in os.environ.items():
if key.startswith("DEEPGRAM_STT_"):
# Extract parameter name and convert to lowercase
param_name = key.replace("DEEPGRAM_STT_", "", 1).lower()
# Check if parameter is supported by LiveKit plugin
if param_name not in DEEPGRAM_STT_SUPPORTED_PARAMS:
supported = ", ".join(sorted(DEEPGRAM_STT_SUPPORTED_PARAMS))
logger.warning(
f"Ignoring unsupported Deepgram STT parameter: {param_name}. "
f"Supported parameters: {supported}"
)
continue
# Parse value type
value_lower = value.lower()
if value_lower in ("true", "false"):
# Boolean values
stt_kwargs[param_name] = value_lower == "true"
elif value.isdigit():
# Integer values
stt_kwargs[param_name] = int(value)
else:
# String values
stt_kwargs[param_name] = value
logger.info(f"Deepgram STT configuration: {stt_kwargs}")
return stt_kwargs
class Transcriber(Agent):
@@ -57,11 +89,12 @@ class Transcriber(Agent):
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
stt = create_stt_provider()
# Build STT configuration from environment variables
stt_kwargs = _build_deepgram_stt_kwargs()
super().__init__(
instructions="not-needed",
stt=stt,
stt=deepgram.STT(**stt_kwargs),
)
self.participant_identity = participant_identity
@@ -123,14 +156,19 @@ 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,
participant=participant,
options=lk_room_io.RoomOptions(
text_input=False, audio_output=False, text_output=True
input_options=RoomInputOptions(
text_enabled=False,
),
output_options=RoomOutputOptions(
transcription_enabled=True,
audio_enabled=False,
),
)
await room_io.start()
@@ -193,8 +231,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__":
+4 -5
View File
@@ -1,13 +1,12 @@
[project]
name = "agents"
version = "1.2.0"
version = "1.0.1"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
"livekit-plugins-deepgram==1.3.10",
"livekit-plugins-silero==1.3.10",
"livekit-plugins-kyutai-lasuite==0.0.6",
"livekit-agents==1.2.18",
"livekit-plugins-deepgram==1.2.18",
"livekit-plugins-silero==1.2.18",
"python-dotenv==1.2.1"
]
-6
View File
@@ -181,7 +181,6 @@ class RecordingSerializer(serializers.ModelSerializer):
"updated_at",
"status",
"mode",
"options",
"key",
"is_expired",
"expired_at",
@@ -213,11 +212,6 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
"screen_recording or transcript.",
},
)
options = serializers.JSONField(
required=False,
allow_null=True,
default=dict,
)
class RequestEntrySerializer(BaseValidationOnlySerializer):
+1 -4
View File
@@ -308,13 +308,10 @@ class RoomViewSet(
)
mode = serializer.validated_data["mode"]
options = serializer.validated_data["options"]
room = self.get_object()
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room, mode=mode, options=options
)
recording = models.Recording.objects.create(room=room, mode=mode)
models.RecordingAccess.objects.create(
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
+5 -28
View File
@@ -95,34 +95,11 @@ class ApplicationViewSet(viewsets.GenericViewSet):
try:
user = models.User.objects.get(email=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
):
# Create a provisional user without `sub`, identified by email only.
#
# This relies on Django LaSuite implicitly updating the `sub` field on the
# user's first successful OIDC authentication. If this stops working,
# check for behavior changes in Django LaSuite.
#
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
# updates. We override its default value to allow setting `sub` for
# provisional users.
user = models.User(
sub=None,
email=email,
)
user.set_unusable_password()
user.save()
logger.info(
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
user.id,
email,
application.client_id,
)
else:
raise drf_exceptions.NotFound("User not found.") from e
raise drf_exceptions.NotFound(
{
"error": "User not found.",
}
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
+1 -1
View File
@@ -41,7 +41,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
@@ -1,18 +0,0 @@
# Generated by Django 5.2.9 on 2025-12-29 15:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0015_application_and_more'),
]
operations = [
migrations.AddField(
model_name='recording',
name='options',
field=models.JSONField(blank=True, default=dict, help_text='Recording options', verbose_name='Recording options'),
),
]
+1 -8
View File
@@ -146,8 +146,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
sub = models.CharField(
_("sub"),
help_text=_(
"Optional for pending users; required upon account activation. "
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
@@ -577,12 +576,6 @@ class Recording(BaseModel):
verbose_name=_("Recording mode"),
help_text=_("Defines the mode of recording being called."),
)
options = models.JSONField(
blank=True,
default=dict,
verbose_name=_("Recording options"),
help_text=_("Recording options"),
)
class Meta:
db_table = "meet_recording"
@@ -16,23 +16,6 @@ from core import models
logger = logging.getLogger(__name__)
def get_recording_download_base_url() -> str:
"""Get the recording download base URL with backward compatibility."""
new_setting = settings.RECORDING_DOWNLOAD_BASE_URL
old_setting = settings.SCREEN_RECORDING_BASE_URL
if old_setting:
logger.warning(
"SCREEN_RECORDING_BASE_URL is deprecated and will be removed in a future version. "
"Please use RECORDING_DOWNLOAD_BASE_URL instead."
)
if new_setting:
return new_setting
return old_setting
class NotificationService:
"""Service for processing recordings and notifying external services."""
@@ -43,12 +26,7 @@ class NotificationService:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
summary_success = True
if recording.options.get("transcribe", False):
summary_success = self._notify_summary_service(recording)
email_success = self._notify_user_by_email(recording)
return email_success and summary_success
return self._notify_user_by_email(recording)
logger.error(
"Unknown recording mode %s for recording %s",
@@ -86,7 +64,7 @@ class NotificationService:
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{get_recording_download_base_url()}/{recording.id}",
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
}
has_failures = False
@@ -159,14 +137,12 @@ class NotificationService:
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"language": recording.options.get("language"),
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
}
headers = {
@@ -1,11 +1,7 @@
"""Recording-related LiveKit Events Service"""
# pylint: disable=no-member
from logging import getLogger
from livekit import api
from core import models, utils
logger = getLogger(__name__)
@@ -18,27 +14,6 @@ class RecordingEventsError(Exception):
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@staticmethod
def handle_update(recording, egress_status):
"""Handle egress status updates and sync recording state to room metadata."""
room_name = str(recording.room.id)
status_mapping = {
api.EgressStatus.EGRESS_ACTIVE: "started",
api.EgressStatus.EGRESS_ENDING: "saving",
api.EgressStatus.EGRESS_ABORTED: "aborted",
}
recording_status = status_mapping.get(egress_status)
if recording_status:
try:
utils.update_room_metadata(
room_name, {"recording_status": recording_status}
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
@staticmethod
def handle_limit_reached(recording):
"""Stop recording and notify participants when limit is reached."""
@@ -2,7 +2,6 @@
import logging
from core import utils
from core.models import Recording, RecordingStatusChoices
from .exceptions import (
@@ -61,15 +60,6 @@ class WorkerServiceMediator:
finally:
recording.save()
mode = recording.options.get("original_mode", None) or recording.mode
try:
utils.update_room_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"}
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
logger.info(
"Worker started for room %s (worker ID: %s)",
recording.room,
+3 -27
View File
@@ -11,7 +11,7 @@ from django.conf import settings
from livekit import api
from core import models, utils
from core import models
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
@@ -118,10 +118,8 @@ class LiveKitEventsService:
except Exception as e:
raise InvalidPayloadError("Invalid webhook payload") from e
room_name = data.room.name or data.egress_info.room_name
if self._filter_regex and not self._filter_regex.search(room_name):
logger.info("Filtered webhook event for room '%s'", room_name)
if self._filter_regex and not self._filter_regex.search(data.room.name):
logger.info("Filtered webhook event for room '%s'", data.room.name)
return
try:
@@ -140,20 +138,6 @@ class LiveKitEventsService:
# pylint: disable=not-callable
handler(data)
def _handle_egress_updated(self, data):
"""Handle 'egress_updated' event."""
egress_id = data.egress_info.egress_id
try:
recording = models.Recording.objects.get(worker_id=egress_id)
except models.Recording.DoesNotExist as err:
raise ActionFailedError(
f"Recording with worker ID {egress_id} does not exist"
) from err
egress_status = data.egress_info.status
self.recording_events.handle_update(recording, egress_status)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
@@ -166,14 +150,6 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
try:
room_name = str(recording.room.id)
utils.update_room_metadata(
room_name, {}, ["recording_mode", "recording_status"]
)
except utils.MetadataUpdateException as e:
logger.exception("Failed to update room's metadata: %s", e)
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
@@ -60,26 +60,6 @@ def test_notify_external_services_screen_recording_mode(mock_notify_email):
mock_notify_email.assert_called_once_with(recording)
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
def test_notify_external_services_screen_recording_mode_with_transcribe(
mock_notify_email, mock_notify_summary
):
"""Test notification routing for screen recording mode with transcribe option."""
service = NotificationService()
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING, options={"transcribe": True}
)
result = service.notify_external_services(recording)
assert result is True
mock_notify_email.assert_called_once_with(recording)
mock_notify_summary.assert_called_once_with(recording)
def test_notify_external_services_unknown_mode(caplog):
"""Test notification for unknown recording mode."""
recording = factories.RecordingFactory()
@@ -82,7 +82,6 @@ def test_api_recordings_list_authenticated_direct(role, settings):
"key": recording.key,
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"mode": recording.mode,
"options": {},
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
@@ -95,7 +95,6 @@ def test_api_recording_retrieve_administrators(settings):
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": None,
"is_expired": False,
}
@@ -131,7 +130,6 @@ def test_api_recording_retrieve_owners(settings):
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": None,
"is_expired": False,
}
@@ -171,7 +169,6 @@ def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": "2023-01-16T12:00:00Z",
"is_expired": False, # Ensure the recording is still valid and hasn't expired
}
@@ -212,7 +209,6 @@ def test_api_recording_retrieve_expired(settings):
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"options": {},
"expired_at": "2023-01-17T12:00:00Z",
"is_expired": True, # Ensure the recording has expired
}
@@ -2,7 +2,6 @@
# pylint: disable=redefined-outer-name,unused-argument
from unittest import mock
from unittest.mock import Mock
import pytest
@@ -34,18 +33,14 @@ def mediator(mock_worker_service):
return WorkerServiceMediator(mock_worker_service)
@mock.patch("core.utils.update_room_metadata")
def test_start_recording_success(
mock_update_room_metadata, mediator, mock_worker_service
):
def test_start_recording_success(mediator, mock_worker_service):
"""Test successful recording start"""
# Setup
worker_id = "test-worker-123"
mock_worker_service.start.return_value = worker_id
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED,
worker_id=None,
status=RecordingStatusChoices.INITIATED, worker_id=None
)
mediator.start(mock_recording)
@@ -60,18 +55,12 @@ def test_start_recording_success(
assert mock_recording.worker_id == worker_id
assert mock_recording.status == RecordingStatusChoices.ACTIVE
mock_update_room_metadata.assert_called_once_with(
str(mock_recording.room.id),
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
)
@pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
)
@mock.patch("core.utils.update_room_metadata")
def test_mediator_start_recording_worker_errors(
mock_update_room_metadata, mediator, mock_worker_service, error_class
mediator, mock_worker_service, error_class
):
"""Test handling of various worker errors during start"""
# Setup
@@ -89,8 +78,6 @@ def test_mediator_start_recording_worker_errors(
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
assert mock_recording.worker_id is None
mock_update_room_metadata.assert_not_called()
@pytest.mark.parametrize(
"status",
@@ -103,9 +90,8 @@ def test_mediator_start_recording_worker_errors(
RecordingStatusChoices.ABORTED,
],
)
@mock.patch("core.utils.update_room_metadata")
def test_mediator_start_recording_from_forbidden_status(
mock_update_room_metadata, mediator, mock_worker_service, status
mediator, mock_worker_service, status
):
"""Test handling of various worker errors during start"""
# Setup
@@ -119,8 +105,6 @@ def test_mediator_start_recording_from_forbidden_status(
mock_recording.refresh_from_db()
assert mock_recording.status == status
mock_update_room_metadata.assert_not_called()
def test_mediator_stop_recording_success(mediator, mock_worker_service):
"""Test successful recording stop"""
@@ -21,7 +21,7 @@ from core.services.livekit_events import (
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import MetadataUpdateException, NotificationError
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -70,10 +70,7 @@ def test_initialization(
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_success(
mock_update_room_metadata, mock_notify, mode, notification_type, service
):
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
"""Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
@@ -86,98 +83,13 @@ def test_handle_egress_ended_success(
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == "stopped"
@pytest.mark.parametrize(
("egress_status", "status"),
(
(EgressStatus.EGRESS_ACTIVE, "started"),
(EgressStatus.EGRESS_ENDING, "saving"),
(EgressStatus.EGRESS_ABORTED, "aborted"),
),
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_updated_success(
mock_update_room_metadata, egress_status, status, service
):
"""Should successfully update room's metadata."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": status}
)
@pytest.mark.parametrize(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_updated_non_handled(
mock_update_room_metadata, egress_status, service
):
"""Should ignore certain egress status and don't trigger metadata updates."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_room_metadata.assert_not_called()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_metadata_update_fails(
mock_update_room_metadata, mock_notify, mode, notification_type, service
):
"""Should successfully stop recording when metadata's update fails."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_notification_fails(
mock_update_room_metadata, mock_notify, service
):
def test_handle_egress_ended_notification_fails(mock_notify, service):
"""Should raise ActionFailedError when notification fails but still stop recording."""
recording = RecordingFactory(worker_id="worker-1", status="active")
@@ -196,16 +108,9 @@ def test_handle_egress_ended_notification_fails(
recording.refresh_from_db()
assert recording.status == "stopped"
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_found(
mock_update_room_metadata, mock_notify, service
):
def test_handle_egress_ended_recording_not_found(mock_notify, service):
"""Should raise ActionFailedError when recording doesn't exist."""
recording = RecordingFactory(worker_id="worker-1", status="active")
@@ -219,17 +124,13 @@ def test_handle_egress_ended_recording_not_found(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_active(
mock_update_room_metadata, mock_notify, service
):
def test_handle_egress_ended_recording_not_active(mock_notify, service):
"""Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
@@ -240,19 +141,13 @@ def test_handle_egress_ended_recording_not_active(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
recording.refresh_from_db()
assert recording.status == "failed_to_stop"
@mock.patch("core.utils.notify_participants")
@mock.patch("core.utils.update_room_metadata")
def test_handle_egress_ended_recording_not_limit_reached(
mock_update_room_metadata, mock_notify, service
):
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
"""Should ignore egress non-limit-reached statuses."""
recording = RecordingFactory(worker_id="worker-1", status="stopped")
@@ -263,9 +158,6 @@ def test_handle_egress_ended_recording_not_limit_reached(
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
mock_update_room_metadata.assert_called_once_with(
str(recording.room.id), {}, ["recording_mode", "recording_status"]
)
assert recording.status == "stopped"
@@ -14,7 +14,7 @@ from core.factories import (
ApplicationFactory,
UserFactory,
)
from core.models import ApplicationScope, User
from core.models import ApplicationScope
pytestmark = pytest.mark.django_db
@@ -232,7 +232,6 @@ def test_api_applications_token_payload_structure(settings):
"""Generated token should have correct payload structure."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -274,119 +273,3 @@ def test_api_applications_token_payload_structure(settings):
"delegated": True,
"scope": "rooms:list rooms:create",
}
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_new_user(settings):
"""Should create a new pending user when creation is allowed and user doesn't exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
assert len(User.objects.all()) == 0
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "unknown@world.com",
},
format="json",
)
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
user = User.objects.get(email="unknown@world.com")
assert user.sub is None
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
@freeze_time("2023-01-15 12:00:00")
def test_api_applications_token_existing_user(settings):
"""Application should not create a new user when user exist."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory(email="user@example.com")
settings.APPLICATION_ALLOW_USER_CREATION = True
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
assert len(User.objects.all()) == 1
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
# Assert no new user was created
assert len(User.objects.all()) == 1
# Decode token to verify payload
token = response.data["access_token"]
payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
assert payload == {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"client_id": application.client_id,
"exp": 1673787600,
"iat": 1673784000,
"user_id": str(user.id),
"delegated": True,
"scope": "rooms:list rooms:create",
}
-52
View File
@@ -25,7 +25,6 @@ from livekit.api import ( # pylint: disable=E0611
LiveKitAPI,
SendDataRequest,
TwirpError,
UpdateRoomMetadataRequest,
VideoGrants,
)
@@ -245,57 +244,6 @@ async def notify_participants(room_name: str, notification_data: dict):
await lkapi.aclose()
class MetadataUpdateException(Exception):
"""Room's metadata update fails."""
@async_to_sync
async def update_room_metadata(
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
):
"""Update LiveKit room metadata by merging new values with existing metadata.
Args:
room_name: Name of the room to update
metadata: Dictionary of metadata key-values to add/update
remove_keys: Optional list of keys to remove from existing metadata.
"""
lkapi = create_livekit_client()
try:
response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
if not response.rooms:
return
room = response.rooms[0]
existing_metadata = json.loads(room.metadata) if room.metadata else {}
if remove_keys:
for key in remove_keys:
existing_metadata.pop(key, None)
updated_metadata = {**existing_metadata, **metadata}
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
)
)
except TwirpError as e:
raise MetadataUpdateException(
f"Failed to update metadata for room {room_name}: {e}"
) from e
finally:
await lkapi.aclose()
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
Binary file not shown.
+62 -67
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+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"
@@ -177,61 +177,61 @@ msgstr "Sub"
#: core/models.py:149
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
#: core/models.py:158
#: core/models.py:157
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:163
#: core/models.py:162
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:165
#: core/models.py:164
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:167
#: core/models.py:166
msgid "short name"
msgstr "Kurzname"
#: core/models.py:173
#: core/models.py:172
msgid "language"
msgstr "Sprache"
#: core/models.py:174
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:180
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:183
#: core/models.py:182
msgid "device"
msgstr "Gerät"
#: core/models.py:185
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:188
#: core/models.py:187
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:190
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:193
#: core/models.py:192
msgid "active"
msgstr "aktiv"
#: core/models.py:196
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -239,66 +239,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:209
#: core/models.py:208
msgid "user"
msgstr "Benutzer"
#: core/models.py:210
#: core/models.py:209
msgid "users"
msgstr "Benutzer"
#: core/models.py:269
#: core/models.py:268
msgid "Resource"
msgstr "Ressource"
#: core/models.py:270
#: core/models.py:269
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:324
#: core/models.py:323
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:325
#: core/models.py:324
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:331
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:387
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:388
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:395
#: core/models.py:394
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:396
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:402 core/models.py:556
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Raum"
#: core/models.py:403
#: core/models.py:402
msgid "Rooms"
msgstr "Räume"
#: core/models.py:567
#: core/models.py:566
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:569
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,104 +307,99 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:577
#: core/models.py:576
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:578
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:590
#: core/models.py:583
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:591
#: core/models.py:584
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:699
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:700
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:706
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:712
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:718
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:735
#: core/models.py:728
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:736
#: core/models.py:729
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:737
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:738
#: core/models.py:731
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:739
#: core/models.py:732
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:752
#: core/models.py:745
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:753
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:763
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
msgstr "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:774
#: core/models.py:767
msgid "Application"
msgstr "Anwendung"
#: core/models.py:775
#: core/models.py:768
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:798
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:801
#: core/models.py:794
msgid "Domain"
msgstr "Domain"
#: core/models.py:802
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:814
#: core/models.py:807
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:815
#: core/models.py:808
msgid "Application domains"
msgstr "Anwendungsdomains"
@@ -536,18 +531,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:167
msgid "English"
msgstr "Englisch"
#: meet/settings.py:170
#: meet/settings.py:168
msgid "French"
msgstr "Französisch"
#: meet/settings.py:171
#: meet/settings.py:169
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:172
#: meet/settings.py:170
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+59 -63
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+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"
@@ -175,61 +175,61 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:158
#: core/models.py:157
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:163
#: core/models.py:162
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:165
#: core/models.py:164
msgid "full name"
msgstr "full name"
#: core/models.py:167
#: core/models.py:166
msgid "short name"
msgstr "short name"
#: core/models.py:173
#: core/models.py:172
msgid "language"
msgstr "language"
#: core/models.py:174
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:180
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:183
#: core/models.py:182
msgid "device"
msgstr "device"
#: core/models.py:185
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:188
#: core/models.py:187
msgid "staff status"
msgstr "staff status"
#: core/models.py:190
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:193
#: core/models.py:192
msgid "active"
msgstr "active"
#: core/models.py:196
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -237,63 +237,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:209
#: core/models.py:208
msgid "user"
msgstr "user"
#: core/models.py:210
#: core/models.py:209
msgid "users"
msgstr "users"
#: core/models.py:269
#: core/models.py:268
msgid "Resource"
msgstr "Resource"
#: core/models.py:270
#: core/models.py:269
msgid "Resources"
msgstr "Resources"
#: core/models.py:324
#: core/models.py:323
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:325
#: core/models.py:324
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:331
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:387
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:388
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:395
#: core/models.py:394
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:396
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:402 core/models.py:556
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Room"
#: core/models.py:403
#: core/models.py:402
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:567
#: core/models.py:566
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:569
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -301,107 +301,103 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:577
#: core/models.py:576
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:578
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:590
#: core/models.py:583
msgid "Recording"
msgstr "Recording"
#: core/models.py:591
#: core/models.py:584
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:699
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:700
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:706
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:712
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:718
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:735
#: core/models.py:728
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:736
#: core/models.py:729
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:737
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:738
#: core/models.py:731
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:739
#: core/models.py:732
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:752
#: core/models.py:745
msgid "Application name"
msgstr "Application name"
#: core/models.py:753
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:763
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:774
#: core/models.py:767
msgid "Application"
msgstr "Application"
#: core/models.py:775
#: core/models.py:768
msgid "Applications"
msgstr "Applications"
#: core/models.py:798
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:801
#: core/models.py:794
msgid "Domain"
msgstr "Domain"
#: core/models.py:802
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:814
#: core/models.py:807
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:815
#: core/models.py:808
msgid "Application domains"
msgstr "Application domains"
@@ -533,18 +529,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:167
msgid "English"
msgstr "English"
#: meet/settings.py:170
#: meet/settings.py:168
msgid "French"
msgstr "French"
#: meet/settings.py:171
#: meet/settings.py:169
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:172
#: meet/settings.py:170
msgid "German"
msgstr "German"
Binary file not shown.
+62 -68
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+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"
@@ -179,61 +179,61 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:158
#: core/models.py:157
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:163
#: core/models.py:162
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:165
#: core/models.py:164
msgid "full name"
msgstr "nom complet"
#: core/models.py:167
#: core/models.py:166
msgid "short name"
msgstr "nom court"
#: core/models.py:173
#: core/models.py:172
msgid "language"
msgstr "langue"
#: core/models.py:174
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:180
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:183
#: core/models.py:182
msgid "device"
msgstr "appareil"
#: core/models.py:185
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:188
#: core/models.py:187
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:190
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:193
#: core/models.py:192
msgid "active"
msgstr "actif"
#: core/models.py:196
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -241,65 +241,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:209
#: core/models.py:208
msgid "user"
msgstr "utilisateur"
#: core/models.py:210
#: core/models.py:209
msgid "users"
msgstr "utilisateurs"
#: core/models.py:269
#: core/models.py:268
msgid "Resource"
msgstr "Ressource"
#: core/models.py:270
#: core/models.py:269
msgid "Resources"
msgstr "Ressources"
#: core/models.py:324
#: core/models.py:323
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:325
#: core/models.py:324
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:331
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:387
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:388
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:395
#: core/models.py:394
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:396
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:402 core/models.py:556
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Salle"
#: core/models.py:403
#: core/models.py:402
msgid "Rooms"
msgstr "Salles"
#: core/models.py:567
#: core/models.py:566
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:569
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,105 +307,99 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:577
#: core/models.py:576
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:578
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:590
#: core/models.py:583
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:591
#: core/models.py:584
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:699
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:700
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:706
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:712
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:718
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:735
#: core/models.py:728
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:736
#: core/models.py:729
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:737
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:738
#: core/models.py:731
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:739
#: core/models.py:732
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:752
#: core/models.py:745
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:753
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:763
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
msgstr "Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun nouveau secret."
#: core/models.py:774
#: core/models.py:767
msgid "Application"
msgstr "Application"
#: core/models.py:775
#: core/models.py:768
msgid "Applications"
msgstr "Applications"
#: core/models.py:798
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:801
#: core/models.py:794
msgid "Domain"
msgstr "Domaine"
#: core/models.py:802
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:814
#: core/models.py:807
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:815
#: core/models.py:808
msgid "Application domains"
msgstr "Domaines dapplication"
@@ -537,18 +531,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:167
msgid "English"
msgstr "Anglais"
#: meet/settings.py:170
#: meet/settings.py:168
msgid "French"
msgstr "Français"
#: meet/settings.py:171
#: meet/settings.py:169
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:172
#: meet/settings.py:170
msgid "German"
msgstr "Allemand"
Binary file not shown.
+61 -67
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2025-11-13 16:21+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"
@@ -176,61 +176,60 @@ msgstr "sub"
#: core/models.py:149
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
#: core/models.py:158
#: core/models.py:157
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:163
#: core/models.py:162
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:165
#: core/models.py:164
msgid "full name"
msgstr "volledige naam"
#: core/models.py:167
#: core/models.py:166
msgid "short name"
msgstr "korte naam"
#: core/models.py:173
#: core/models.py:172
msgid "language"
msgstr "taal"
#: core/models.py:174
#: core/models.py:173
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:180
#: core/models.py:179
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:183
#: core/models.py:182
msgid "device"
msgstr "apparaat"
#: core/models.py:185
#: core/models.py:184
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:188
#: core/models.py:187
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:190
#: core/models.py:189
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:193
#: core/models.py:192
msgid "active"
msgstr "actief"
#: core/models.py:196
#: core/models.py:195
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -238,64 +237,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:209
#: core/models.py:208
msgid "user"
msgstr "gebruiker"
#: core/models.py:210
#: core/models.py:209
msgid "users"
msgstr "gebruikers"
#: core/models.py:269
#: core/models.py:268
msgid "Resource"
msgstr "Bron"
#: core/models.py:270
#: core/models.py:269
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:324
#: core/models.py:323
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:325
#: core/models.py:324
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:331
#: core/models.py:330
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:387
#: core/models.py:386
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:388
#: core/models.py:387
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:395
#: core/models.py:394
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:396
#: core/models.py:395
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:402 core/models.py:556
#: core/models.py:401 core/models.py:555
msgid "Room"
msgstr "Ruimte"
#: core/models.py:403
#: core/models.py:402
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:567
#: core/models.py:566
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:569
#: core/models.py:568
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -303,104 +302,99 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:577
#: core/models.py:576
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:578
#: core/models.py:577
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:583 core/models.py:584
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:590
#: core/models.py:583
msgid "Recording"
msgstr "Opname"
#: core/models.py:591
#: core/models.py:584
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:699
#: core/models.py:692
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:700
#: core/models.py:693
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:706
#: core/models.py:699
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:712
#: core/models.py:705
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:718
#: core/models.py:711
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:735
#: core/models.py:728
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:736
#: core/models.py:729
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:737
#: core/models.py:730
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:738
#: core/models.py:731
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:739
#: core/models.py:732
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:752
#: core/models.py:745
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:753
#: core/models.py:746
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:763
#: core/models.py:756
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
msgstr "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:774
#: core/models.py:767
msgid "Application"
msgstr "Applicatie"
#: core/models.py:775
#: core/models.py:768
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:798
#: core/models.py:791
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:801
#: core/models.py:794
msgid "Domain"
msgstr "Domein"
#: core/models.py:802
#: core/models.py:795
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:814
#: core/models.py:807
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:815
#: core/models.py:808
msgid "Application domains"
msgstr "Applicatiedomeinen"
@@ -532,18 +526,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:167
msgid "English"
msgstr "Engels"
#: meet/settings.py:170
#: meet/settings.py:168
msgid "French"
msgstr "Frans"
#: meet/settings.py:171
#: meet/settings.py:169
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:172
#: meet/settings.py:170
msgid "German"
msgstr "Duits"
+3 -18
View File
@@ -342,12 +342,12 @@ class Base(Configuration):
"use_proconnect_button": values.BooleanValue(
False, environ_name="FRONTEND_USE_PROCONNECT_BUTTON", environ_prefix=None
),
"transcript": values.DictValue(
{}, environ_name="FRONTEND_TRANSCRIPT", environ_prefix=None
),
"manifest_link": values.Value(
None, environ_name="FRONTEND_MANIFEST_LINK", environ_prefix=None
),
"transcription_destination": values.Value(
None, environ_name="FRONTEND_TRANSCRIPTION_DESTINATION", environ_prefix=None
),
}
# Mail
@@ -405,10 +405,6 @@ class Base(Configuration):
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
default=False,
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
environ_prefix=None,
)
OIDC_USER_SUB_FIELD_IMMUTABLE = values.BooleanValue(
default=True, environ_name="OIDC_USER_SUB_FIELD_IMMUTABLE", environ_prefix=None
)
OIDC_TIMEOUT = values.IntegerValue(
5, environ_name="OIDC_TIMEOUT", environ_prefix=None
@@ -634,9 +630,6 @@ class Base(Configuration):
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
RECORDING_DOWNLOAD_BASE_URL = values.Value(
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
@@ -777,14 +770,6 @@ class Base(Configuration):
environ_name="APPLICATION_BASE_URL",
environ_prefix=None,
)
# Allows third-party platforms to create users with email-only identification.
# Required for external integrations, but fragile due to deferred user reconciliation
# on sub. Enable it with care /!\
APPLICATION_ALLOW_USER_CREATION = values.BooleanValue(
False,
environ_name="APPLICATION_ALLOW_USER_CREATION",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.2.0"
version = "1.0.1"
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]
+25 -28
View File
@@ -1,15 +1,13 @@
{
"name": "meet",
"version": "1.2.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.2.0",
"version": "1.0.1",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
@@ -106,6 +104,7 @@
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -1020,24 +1019,6 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@fontsource-variable/material-symbols-outlined": {
"version": "5.2.30",
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.30.tgz",
"integrity": "sha512-BjSx7nqvISJs2Pjd8sBH583AnD4k6dD4Em7AVISoLXzbX3PIFWAE2GPm13LlCys8u8idkyUd62L8yn6ts6DdbA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/material-icons-outlined": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@fontsource/material-icons-outlined/-/material-icons-outlined-5.2.6.tgz",
"integrity": "sha512-99XKAkwnCg0s0/ywax+o3m01HSNM5gGzSBw+WnlWG2+WY3wOjcN+wXMfm4zP37Yme7Yze2DvKxF78tHWOrlwFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@formatjs/ecma402-abstract": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz",
@@ -3889,6 +3870,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz",
"integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.81.5"
},
@@ -3980,8 +3962,7 @@
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.11",
@@ -3997,8 +3978,7 @@
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.15.tgz",
"integrity": "sha512-omOlCPvTWyPm4ZE5bZUhlSvnHM2ZWM2U+1cPiYFL/e8aV5O9MouELp+L4dMKNTON0nTeHqEg+KWDfFQMY5Wkaw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -4025,6 +4005,7 @@
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4042,6 +4023,7 @@
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -4108,6 +4090,7 @@
"integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.1",
"@typescript-eslint/types": "8.35.1",
@@ -4399,6 +4382,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4813,6 +4797,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.5.4",
@@ -5622,6 +5607,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -6589,6 +6575,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -7473,6 +7460,7 @@
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.7.tgz",
"integrity": "sha512-19m8Q1cvRl5PslRawDUgWXeP8vL8584tX8kiZEJaPZo83U/L6VPS/O7pP06phfJaBWeeV8sAOVtEPlQiZEHtpg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.39.3",
@@ -8132,6 +8120,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -8245,6 +8234,7 @@
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -8398,6 +8388,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8504,6 +8495,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8753,7 +8745,6 @@
"integrity": "sha512-GBg5pV8LHOTbeVmH2VHLEFR0mc2QpQMzAvcoxEGfPNWgWHc8UvKCyq7pqN1vA+fDZ+yXXbixeO0kB2pzVvFCBw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"postcss": "^8.4.38"
}
@@ -9257,6 +9248,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -9375,7 +9367,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -9487,6 +9480,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9647,6 +9641,7 @@
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"proxy-compare": "^3.0.1"
},
@@ -9747,6 +9742,7 @@
"integrity": "sha512-cJBdq0/u+8rgstg9t7UkBilf8ipLmeXJO30NxD5HAHOivnj10ocV8YtR/XBvd2wQpN3TmcaxNKaHX3tN7o5F5A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
@@ -9881,6 +9877,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+1 -3
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.2.0",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,8 +13,6 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.30",
"@fontsource/material-icons-outlined": "5.2.6",
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
+3 -1
View File
@@ -17,6 +17,9 @@ export interface ApiConfig {
feedback: {
url: string
}
transcript: {
form_beta_users: string
}
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
custom_css_url?: string
@@ -44,7 +47,6 @@ export interface ApiConfig {
enable_firefox_proxy_workaround: boolean
default_sources: string[]
}
transcription_destination?: string
}
const fetchConfig = (): Promise<ApiConfig> => {
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"
@@ -1,9 +1,10 @@
import { styled } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { Button } from '@/primitives'
import { Button, LinkButton } from '@/primitives'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useConfig } from '@/api/useConfig'
const Heading = styled('h2', {
base: {
@@ -164,7 +165,14 @@ export const IntroSlider = () => {
const [slideIndex, setSlideIndex] = useState(0)
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
const NUMBER_SLIDES = SLIDES.length
const { data } = useConfig()
const filteredSlides = useMemo(
() => (data?.transcript?.form_beta_users ? SLIDES : SLIDES.slice(0, 2)),
[data]
)
const NUMBER_SLIDES = filteredSlides.length
return (
<Container>
@@ -190,12 +198,24 @@ export const IntroSlider = () => {
</ButtonVerticalCenter>
</ButtonContainer>
<SlideContainer>
{SLIDES.map((slide, index) => (
{filteredSlides.map((slide, index) => (
<Slide visible={index == slideIndex} key={index}>
<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>
{slide.isAvailableInBeta && (
<LinkButton
href={data?.transcript.form_beta_users}
target="_blank"
tooltip={t('beta.tooltip')}
variant={'primary'}
size={'sm'}
style={{ marginTop: '1rem', width: 'fit-content' }}
>
{t('beta.text')}
</LinkButton>
)}
</TextAnimation>
</Slide>
))}
@@ -221,7 +241,7 @@ export const IntroSlider = () => {
display: { base: 'none', xsm: 'block' },
})}
>
{SLIDES.map((_, index) => (
{filteredSlides.map((_, index) => (
<Dot key={index} selected={index == slideIndex} />
))}
</div>
@@ -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]
}
@@ -104,16 +104,6 @@ export const MainNotificationToast = () => {
{ timeout: NotificationDuration.ALERT }
)
break
case NotificationType.TranscriptionRequested:
case NotificationType.ScreenRecordingRequested:
toastQueue.add(
{
participant,
type: notification.type,
},
{ timeout: NotificationDuration.RECORDING_REQUESTED }
)
break
case NotificationType.PermissionsRemoved: {
const removedSources = notification?.data?.removedSources
if (!removedSources?.length) break
@@ -13,5 +13,4 @@ export const NotificationDuration = {
LOWER_HAND: ToastDuration.EXTRA_LONG,
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
REACTION_RECEIVED: ToastDuration.SHORT,
RECORDING_REQUESTED: ToastDuration.LONG,
} as const
@@ -9,10 +9,8 @@ export enum NotificationType {
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
TranscriptionLimitReached = 'transcriptionLimitReached',
TranscriptionRequested = 'transcriptionRequested',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingRequested = 'screenRecordingRequested',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
@@ -1,89 +0,0 @@
import { useToast } from '@react-aria/toast'
import { useMemo, useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { NotificationType } from '../NotificationType'
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
export function ToastRecordingRequest({
state,
...props
}: Readonly<ToastProps>) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant
const type = props.toast.content.type
const {
isTranscriptOpen,
openTranscript,
isScreenRecordingOpen,
openScreenRecording,
} = useSidePanel()
const options = useMemo(() => {
switch (type) {
case NotificationType.TranscriptionRequested:
return {
key: 'transcript.requested',
isMenuOpen: isTranscriptOpen,
openMenu: openTranscript,
}
case NotificationType.ScreenRecordingRequested:
return {
key: 'screenRecording.requested',
isMenuOpen: isScreenRecordingOpen,
openMenu: openScreenRecording,
}
default:
return
}
}, [
type,
isTranscriptOpen,
isScreenRecordingOpen,
openTranscript,
openScreenRecording,
])
if (!options) return
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(options.key, {
name: participant?.name,
})}
{!options.isMenuOpen && (
<div
className={css({
marginLeft: '0.5rem',
})}
>
<Button
size="sm"
variant="text"
className={css({
color: 'primary.300',
})}
onPress={options.openMenu}
>
{t('openMenu')}
</Button>
</div>
)}
</HStack>
</StyledToastContainer>
)
}
@@ -12,7 +12,6 @@ import { ToastLowerHand } from './ToastLowerHand'
import { ToastAnyRecording } from './ToastAnyRecording'
import { ToastRecordingSaving } from './ToastRecordingSaving'
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -53,12 +52,6 @@ const renderToast = (
case NotificationType.ScreenRecordingLimitReached:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
case NotificationType.TranscriptionRequested:
case NotificationType.ScreenRecordingRequested:
return (
<ToastRecordingRequest key={toast.key} toast={toast} state={state} />
)
case NotificationType.RecordingSaving:
return (
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
@@ -7,19 +7,16 @@ import { RecordingMode } from '../types'
export interface StartRecordingParams {
id: string
mode?: RecordingMode
options?: Record<string, string | boolean>
}
const startRecording = ({
id,
mode = RecordingMode.Transcript,
options,
}: StartRecordingParams): Promise<ApiRoom> => {
return fetchApi(`rooms/${id}/start-recording/`, {
method: 'POST',
body: JSON.stringify({
mode: mode,
options: options,
}),
})
}
@@ -1,183 +0,0 @@
import { css, cx } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner'
import { Button, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { ConnectionState } from 'livekit-client'
import { Button as RACButton } from 'react-aria-components'
import { parseLineBreaks } from '@/utils/parseLineBreaks'
const Layout = ({ children }: { children: ReactNode }) => (
<div
className={css({
marginBottom: '80px',
width: '100%',
})}
>
{children}
</div>
)
interface ControlsButtonProps {
i18nKeyPrefix: string
statuses: RecordingStatuses
handle: () => void
isPendingToStart: boolean
isPendingToStop: boolean
openSidePanel: () => void
}
const MIN_SPINNER_DISPLAY_TIME = 2000
export const ControlsButton = ({
i18nKeyPrefix,
statuses,
handle,
isPendingToStart,
isPendingToStop,
openSidePanel,
}: 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
const [showSaving, setShowSaving] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout>()
const isSaving = statuses.isSaving || isPendingToStop
const isDisabled = !isRoomConnected || statuses.isAnotherModeStarted
useEffect(() => {
if (isSaving) {
clearTimeout(timeoutRef.current)
setShowSaving(true)
} else if (showSaving) {
timeoutRef.current = setTimeout(() => {
setShowSaving(false)
}, MIN_SPINNER_DISPLAY_TIME)
}
return () => clearTimeout(timeoutRef.current)
}, [isSaving, showSaving])
// Saving state
if (showSaving) {
return (
<Layout>
<HStack width="100%" height="46px" justify="center">
<Spinner size={30} />
<Text variant="body">{t('button.saving')}</Text>
</HStack>
</Layout>
)
}
// Starting state
if (statuses.isStarting || isPendingToStart) {
return (
<Layout>
<HStack width="100%" height="46px" justify="center">
<Spinner size={30} />
{t('button.starting')}
</HStack>
</Layout>
)
}
// Active state (Stop button)
if (statuses.isStarted) {
return (
<Layout>
<Button
variant="tertiary"
fullWidth
onPress={handle}
isDisabled={isDisabled}
ref={primaryActionRef}
>
{t('button.stop')}
</Button>
</Layout>
)
}
// Inactive state (Start button)
return (
<Layout>
{statuses.isAnotherModeStarted && (
<RACButton
className={css({
backgroundColor: 'primary.50',
border: '1px solid',
borderColor: 'primary.200',
borderRadius: '6px',
padding: '0.75rem',
marginBottom: '0.75rem',
display: 'flex',
justifyContent: 'left',
textAlign: 'left',
alignItems: 'center',
width: '100%',
cursor: 'pointer',
_hover: {
backgroundColor: 'primary.100',
borderColor: 'primary.400',
},
})}
onPress={() => openSidePanel()}
>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginRight: '1rem',
})
)}
>
info
</span>
<Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))}
</Text>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginLeft: 'auto',
})
)}
>
chevron_right
</span>
</RACButton>
)}
<Button
variant={isDisabled ? 'primary' : 'tertiary'}
fullWidth
onPress={handle}
isDisabled={isDisabled}
size="compact"
ref={primaryActionRef}
>
{t('button.start')}
</Button>
</Layout>
)
}
@@ -1,29 +0,0 @@
import { Button, Dialog, P } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { recordingStore } from '@/stores/recording'
export const ErrorAlertDialog = () => {
const recordingSnap = useSnapshot(recordingStore)
const { t } = useTranslation('rooms', {
keyPrefix: 'errorRecordingAlertDialog',
})
return (
<Dialog
isOpen={!!recordingSnap.isErrorDialogOpen}
role="alertdialog"
title={t('title')}
aria-label={t('title')}
>
<P>{t(`body.${recordingSnap.isErrorDialogOpen}`)}</P>
<Button
variant="text"
size="sm"
onPress={() => (recordingStore.isErrorDialogOpen = '')}
>
{t('button')}
</Button>
</Dialog>
)
}
@@ -1,61 +1,35 @@
import { useTranslation } from 'react-i18next'
import { Button, Dialog, P } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { useHumanizeRecordingMaxDuration } from '@/features/recording'
import { useEffect, useState } from 'react'
import { NotificationType } from '@/features/notifications'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { useRoomContext } from '@livekit/components-react'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
export const LimitReachedAlertDialog = () => {
const [isAlertOpen, setIsAlertOpen] = useState(false)
const { t } = useTranslation('rooms', {
export const LimitReachedAlertDialog = ({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) => {
const { t, i18n } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast.limitReachedAlert',
})
const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const maxDuration = useHumanizeRecordingMaxDuration()
useEffect(() => {
const handleDataReceived = (payload: Uint8Array) => {
if (!isAdminOrOwner) return
const notification = decodeNotificationDataReceived(payload)
if (
notification?.type === NotificationType.TranscriptionLimitReached ||
notification?.type === NotificationType.ScreenRecordingLimitReached
) {
setIsAlertOpen(true)
}
}
room.on(RoomEvent.DataReceived, handleDataReceived)
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
}
}, [room, isAdminOrOwner])
if (!isAdminOrOwner) return null
const { data } = useConfig()
return (
<Dialog isOpen={isAlertOpen} role="alertdialog" title={t('title')}>
<Dialog isOpen={isOpen} role="alertdialog" title={t('title')}>
<P>
{t('description', {
duration_message: maxDuration
duration_message: data?.recording?.max_duration
? t('durationMessage', {
duration: maxDuration,
duration: humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
}),
})
: '',
})}
</P>
<HStack gap={1}>
<Button variant="text" size="sm" onPress={() => setIsAlertOpen(false)}>
<Button variant="text" size="sm" onPress={onClose}>
{t('button')}
</Button>
</HStack>
@@ -1,46 +0,0 @@
import { H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { LoginButton } from '@/components/LoginButton'
import { HStack } from '@/styled-system/jsx'
interface LoginPromptProps {
heading: string
body: string
}
export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
return (
<div
className={css({
backgroundColor: 'primary.50',
borderRadius: '5px',
border: '1px solid',
borderColor: 'primary.200',
paddingY: '1rem',
paddingX: '1rem',
marginTop: '1rem',
display: 'flex',
flexDirection: 'column',
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<span className="material-symbols" aria-hidden={true}>
login
</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
</HStack>
<Text variant="smNote" wrap="pretty">
{body}
</Text>
<div
className={css({
marginTop: '1rem',
})}
>
<LoginButton proConnectHint={false} />
</div>
</div>
)
}
@@ -1,109 +0,0 @@
import { A, Div, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { LoginPrompt } from './LoginPrompt'
import { RequestRecording } from './RequestRecording'
import { useUser } from '@/features/auth'
import { HStack, VStack } from '@/styled-system/jsx'
const Divider = ({ label }: { label: string }) => (
<HStack gap="1rem" alignItems="center" width="100%" marginY="1rem">
<div className={css({ flex: 1, height: '1px', bg: 'neutral.200' })} />
<Text variant="xsNote">{label}</Text>
<div className={css({ flex: 1, height: '1px', bg: 'neutral.200' })} />
</HStack>
)
interface NoAccessViewProps {
i18nKeyPrefix: string
i18nKey: string
helpArticle?: string
imagePath: string
handleRequest: () => Promise<void>
isActive: boolean
}
export const NoAccessView = ({
i18nKeyPrefix,
i18nKey,
helpArticle,
imagePath,
handleRequest,
isActive,
}: NoAccessViewProps) => {
const { isLoggedIn } = useUser()
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img
src={imagePath}
alt=""
className={css({
minHeight: '250px',
height: '250px',
marginBottom: '1rem',
marginTop: '-16px',
'@media (max-height: 900px)': {
height: 'auto',
minHeight: 'auto',
maxHeight: '25%',
marginBottom: '0.75rem',
},
'@media (max-height: 770px)': {
display: 'none',
},
})}
/>
<VStack gap={0} marginBottom={0}>
<H lvl={1} margin={'sm'} fullWidth centered>
{t(`${i18nKey}.heading`)}
</H>
<Text
variant="note"
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
'@media (max-height: 700px)': {
marginBottom: '1rem',
},
})}
>
{t(`${i18nKey}.body`)}
<br />
{helpArticle && (
<A href={helpArticle} target="_blank">
{t(`${i18nKey}.linkMore`)}
</A>
)}
</Text>
</VStack>
{!isLoggedIn && (
<LoginPrompt
heading={t(`${i18nKey}.login.heading`)}
body={t(`${i18nKey}.login.body`)}
/>
)}
{!isLoggedIn && !isActive && (
<Divider label={t(`${i18nKey}.dividerLabel`)} />
)}
{!isActive && (
<RequestRecording
heading={t(`${i18nKey}.request.heading`)}
body={t(`${i18nKey}.request.body`)}
buttonLabel={t(`${i18nKey}.request.buttonLabel`)}
handleRequest={handleRequest}
/>
)}
</Div>
)
}
@@ -1,13 +0,0 @@
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
import { RecordingStateToast } from './RecordingStateToast'
import { ErrorAlertDialog } from './ErrorAlertDialog'
export const RecordingProvider = () => {
return (
<>
<RecordingStateToast />
<LimitReachedAlertDialog />
<ErrorAlertDialog />
</>
)
}
@@ -1,74 +1,135 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useMemo, useRef, useState, useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { useRoomContext } from '@livekit/components-react'
import { Spinner } from '@/primitives/Spinner'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Text } from '@/primitives'
import { RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { RiRecordCircleLine } from '@remixicon/react'
import {
RecordingMode,
useHasRecordingAccess,
useRecordingStatuses,
useIsRecordingActive,
} from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { Button as RACButton } from 'react-aria-components'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useRoomMetadata } from '../hooks/useRoomMetadata'
import { RecordingStatusIcon } from './RecordingStatusIcon'
import { useIsRecording } from '@livekit/components-react'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast',
})
const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const { openTranscript, openScreenRecording } = useSidePanel()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const recordingSnap = useSnapshot(recordingStore)
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const {
isStarted: isScreenRecordingStarted,
isStarting: isScreenRecordingStarting,
isActive: isScreenRecordingActive,
} = useRecordingStatuses(RecordingMode.ScreenRecording)
const isScreenRecordingActive = useIsRecordingActive(
RecordingMode.ScreenRecording
)
const {
isStarted: isTranscriptStarted,
isStarting: isTranscriptStarting,
isActive: isTranscriptActive,
} = useRecordingStatuses(RecordingMode.Transcript)
useEffect(() => {
if (room.isRecording && recordingSnap.status == RecordingStatus.STOPPED) {
recordingStore.status = RecordingStatus.ANY_STARTED
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room.isRecording])
const isStarted = isScreenRecordingStarted || isTranscriptStarted
const isStarting = isTranscriptStarting || isScreenRecordingStarting
useEffect(() => {
const handleDataReceived = (payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload)
const metadata = useRoomMetadata()
const isRecording = useIsRecording()
if (!notification) return
switch (notification.type) {
case NotificationType.TranscriptionStarted:
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
break
case NotificationType.TranscriptionStopped:
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.TranscriptionLimitReached:
if (isAdminOrOwner) setIsAlertOpen(true)
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.ScreenRecordingStarted:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
break
case NotificationType.ScreenRecordingStopped:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
case NotificationType.ScreenRecordingLimitReached:
if (isAdminOrOwner) setIsAlertOpen(true)
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
default:
return
}
}
const handleRecordingStatusChanged = (status: boolean) => {
if (!status) {
recordingStore.status = RecordingStatus.STOPPED
} else if (recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING) {
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTED
} else if (
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING
) {
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTED
} else {
recordingStore.status = RecordingStatus.ANY_STARTED
}
}
room.on(RoomEvent.DataReceived, handleDataReceived)
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room, recordingSnap, setIsAlertOpen, isAdminOrOwner])
const key = useMemo(() => {
if (!metadata?.recording_status || !metadata?.recording_mode) {
return undefined
switch (recordingSnap.status) {
case RecordingStatus.TRANSCRIPT_STARTED:
return 'transcript.started'
case RecordingStatus.TRANSCRIPT_STARTING:
return 'transcript.starting'
case RecordingStatus.SCREEN_RECORDING_STARTED:
return 'screenRecording.started'
case RecordingStatus.SCREEN_RECORDING_STARTING:
return 'screenRecording.starting'
case RecordingStatus.ANY_STARTED:
return 'any.started'
default:
return
}
if (!isStarting && !isStarted) {
return undefined
}
let status = metadata.recording_status
if (isStarted && !isRecording) {
status = 'starting'
}
return `${metadata.recording_mode}.${status}`
}, [metadata, isStarted, isStarting, isRecording])
}, [recordingSnap])
// Update screen reader message only when the key actually changes
// This prevents duplicate announcements caused by re-renders
@@ -87,7 +148,16 @@ export const RecordingStateToast = () => {
}
}, [key, t])
if (!key) return null
if (!key)
return isAdminOrOwner ? (
<LimitReachedAlertDialog
isOpen={isAlertOpen}
onClose={() => setIsAlertOpen(false)}
aria-label="Recording limit exceeded"
/>
) : null
const isStarted = key?.includes('started')
const hasScreenRecordingAccessAndActive =
isScreenRecordingActive && hasScreenRecordingAccess
@@ -104,6 +174,7 @@ export const RecordingStateToast = () => {
>
{srMessage}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
@@ -121,10 +192,16 @@ export const RecordingStateToast = () => {
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
/>
{isStarted ? (
<RiRecordCircleLine
size={20}
className={css({
animation: 'pulse_background 1s infinite',
})}
/>
) : (
<Spinner size={20} variant="dark" />
)}
{!hasScreenRecordingAccessAndActive &&
!hasTranscriptAccessAndActive && (
@@ -1,21 +0,0 @@
import { Spinner } from '@/primitives/Spinner'
interface RecordingStatusIconProps {
isStarted: boolean
isTranscriptActive: boolean
}
export const RecordingStatusIcon = ({
isStarted,
isTranscriptActive,
}: RecordingStatusIconProps) => {
if (!isStarted) {
return <Spinner size={20} variant="dark" />
}
if (isTranscriptActive) {
return <span className="material-symbols">speech_to_text</span>
}
return <span className="material-symbols">screen_record</span>
}
@@ -1,87 +0,0 @@
import { Button, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
import { Spinner } from '@/primitives/Spinner.tsx'
import { NotificationDuration } from '@/features/notifications/NotificationDuration'
interface RequestRecordingProps {
heading: string
body: string
buttonLabel: string
handleRequest: () => Promise<void>
}
export const RequestRecording = ({
heading,
body,
buttonLabel,
handleRequest,
}: RequestRecordingProps) => {
const [isDisabled, setIsDisabled] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout>()
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const onPress = async () => {
setIsDisabled(true)
try {
await handleRequest()
} catch {
setIsDisabled(false)
return
}
timeoutRef.current = setTimeout(() => {
setIsDisabled(false)
}, NotificationDuration.RECORDING_REQUESTED)
}
return (
<div
className={css({
backgroundColor: 'neutral.50',
borderRadius: '5px',
border: '1px solid',
borderColor: 'neutral.200',
paddingY: '1rem',
paddingX: '1rem',
display: 'flex',
flexDirection: 'column',
marginBottom: '1.5rem',
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<span className="material-symbols">person_raised_hand</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
</HStack>
<Text variant="smNote" wrap="pretty">
{body}
</Text>
<div
className={css({
marginTop: '1rem',
})}
>
<Button
variant="tertiary"
fullWidth
onPress={onPress}
isDisabled={isDisabled}
>
{isDisabled && <Spinner size={24} />}
{buttonLabel}
</Button>
</div>
</div>
)
}
@@ -1,63 +0,0 @@
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
type RowPosition = 'first' | 'middle' | 'last' | 'single'
const BORDER_RADIUS_MAP: Record<RowPosition, string> = {
first: '4px 4px 0 0',
middle: '0',
last: '0 0 4px 4px',
single: '4px',
} as const
interface RowWrapperProps {
iconName: string
children: ReactNode
position?: RowPosition
}
export const RowWrapper = ({
iconName,
children,
position = 'middle',
}: RowWrapperProps) => {
return (
<div
style={{
borderRadius: BORDER_RADIUS_MAP[position],
}}
className={css({
width: '100%',
background: 'gray.100',
paddingBlock: '0.5rem',
paddingInline: '0',
display: 'flex',
marginTop: '0.25rem',
})}
>
<div
className={css({
flex: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingInline: '0.25rem',
})}
>
{/* fixme - doesn't handle properly material-symbols */}
<span className="material-icons">{iconName}</span>
</div>
<div
className={css({
flex: 6,
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
paddingInlineEnd: '8px',
})}
>
{children}
</div>
</div>
)
}
@@ -1,15 +1,18 @@
import { A, Div, H, Text } from '@/primitives'
import { A, Button, Dialog, Div, H, P, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHumanizeRecordingMaxDuration,
useRecordingStatuses,
useIsRecordingTransitioning,
useStartRecording,
useStopRecording,
} from '@/features/recording'
import { useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import {
NotificationType,
@@ -17,48 +20,70 @@ import {
useNotifyParticipants,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
import { useConfig } from '@/api/useConfig'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
import { VStack } from '@/styled-system/jsx'
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'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
export const ScreenRecordingSidePanel = () => {
const { data } = useConfig()
const recordingMaxDuration = useHumanizeRecordingMaxDuration()
const [isLoading, setIsLoading] = useState(false)
const recordingSnap = useSnapshot(recordingStore)
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
const keyPrefix = 'screenRecording'
const { t } = useTranslation('rooms', { keyPrefix })
const [includeTranscript, setIncludeTranscript] = useState(false)
const isAdminOrOwner = useIsAdminOrOwner()
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
const { notifyParticipants } = useNotifyParticipants()
const { selectedLanguageKey, isLanguageSetToAuto } =
useTranscriptionLanguage()
const roomId = useRoomId()
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
useMutateRecording()
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
useStartRecording({
onError: () => setIsErrorDialogOpen('start'),
})
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
useStopRecording({
onError: () => setIsErrorDialogOpen('stop'),
})
const statuses = useRecordingStatuses(RecordingMode.ScreenRecording)
const statuses = useMemo(() => {
return {
isAnotherModeStarted:
recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
isStarting:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING,
isStarted:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
isStopping:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STOPPING,
}
}, [recordingSnap])
const room = useRoomContext()
const { openTranscript } = useSidePanel()
const isRoomConnected = room.state == ConnectionState.Connected
const isRecordingTransitioning = useIsRecordingTransitioning()
const handleRequestScreenRecording = async () => {
await notifyParticipants({
type: NotificationType.ScreenRecordingRequested,
// 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 })
}
})
posthog.capture('screen-recording-requested', {})
}
}, [])
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleScreenRecording = async () => {
if (!roomId) {
@@ -66,10 +91,10 @@ export const ScreenRecordingSidePanel = () => {
return
}
try {
if (statuses.isStarted || statuses.isStarting) {
setIncludeTranscript(false)
await stopRecording({ id: roomId })
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
await notifyParticipants({
type: NotificationType.ScreenRecordingStopped,
})
@@ -78,44 +103,30 @@ export const ScreenRecordingSidePanel = () => {
room.localParticipant
)
} else {
const recordingOptions = {
...(!isLanguageSetToAuto && {
language: selectedLanguageKey,
}),
...(includeTranscript && { transcribe: true }),
}
await startRecording({
await startRecordingRoom({
id: roomId,
mode: RecordingMode.ScreenRecording,
options: recordingOptions,
})
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
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)
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
if (!isAdminOrOwner) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
i18nKey="notAdminOrOwner"
helpArticle={data?.support?.help_article_recording}
imagePath="/assets/intro-slider/4.png"
handleRequest={handleRequestScreenRecording}
isActive={statuses.isActive}
/>
)
}
const isDisabled = useMemo(
() =>
isLoading ||
isRecordingTransitioning ||
statuses.isAnotherModeStarted ||
!isRoomConnected,
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
)
return (
<Div
@@ -128,74 +139,132 @@ export const ScreenRecordingSidePanel = () => {
>
<img
src="/assets/intro-slider/4.png"
alt=""
alt={''}
className={css({
minHeight: '250px',
height: '250px',
minHeight: '309px',
marginBottom: '1rem',
marginTop: '-16px',
'@media (max-height: 900px)': {
height: 'auto',
minHeight: 'auto',
maxHeight: '25%',
marginBottom: '0.75rem',
},
'@media (max-height: 770px)': {
display: 'none',
},
})}
/>
<VStack gap={0} marginBottom={15}>
<H lvl={1} margin={'sm'} fullWidth>
{t('heading')}
</H>
<Text variant="body" fullWidth>
{recordingMaxDuration
? t('body', {
max_duration: recordingMaxDuration,
})
: t('bodyWithoutMaxDuration')}{' '}
{data?.support?.help_article_recording && (
<A href={data.support.help_article_recording} target="_blank">
{t('linkMore')}
</A>
)}
</Text>
</VStack>
<VStack gap={0} marginBottom={25}>
<RowWrapper iconName="cloud_download" position="first">
<Text variant="sm">{t('details.destination')}</Text>
</RowWrapper>
<RowWrapper iconName="mail" position="last">
<Text variant="sm">{t('details.receiver')}</Text>
</RowWrapper>
<div className={css({ height: '15px' })} />
<div
className={css({
width: '100%',
marginLeft: '20px',
})}
>
<Checkbox
size="sm"
isSelected={includeTranscript}
onChange={setIncludeTranscript}
isDisabled={statuses.isActive || isPendingToStart}
{statuses.isStarted ? (
<>
<H lvl={3} margin={false}>
{t('stop.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
<Text variant="sm">{t('details.transcription')}</Text>
</Checkbox>
</div>
</VStack>
<ControlsButton
i18nKeyPrefix={keyPrefix}
handle={handleScreenRecording}
statuses={statuses}
isPendingToStart={isPendingToStart}
isPendingToStop={isPendingToStop}
openSidePanel={openTranscript}
/>
{t('stop.body')}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="stop-screen-recording"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
{statuses.isStopping || isPendingToStop ? (
<>
<H lvl={3} margin={false}>
{t('stopping.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stopping.body')}
</Text>
<Spinner />
</>
) : (
<>
<H lvl={3} margin={false}>
{t('start.heading')}
</H>
<Text
variant="note"
wrap="balance"
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('start.body', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
max_duration: humanizeDuration(
data?.recording?.max_duration,
{
language: i18n.language,
}
),
})
: '',
})}{' '}
{data?.support?.help_article_recording && (
<A href={data.support.help_article_recording} target="_blank">
{t('start.linkMore')}
</A>
)}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="start-screen-recording"
size="sm"
variant="tertiary"
>
{statuses.isStarting || isPendingToStart ? (
<>
<Spinner size={20} />
{t('start.loading')}
</>
) : (
t('start.button')
)}
</Button>
</>
)}
</>
)}
<Dialog
isOpen={!!isErrorDialogOpen}
role="alertdialog"
aria-label={t('alert.title')}
>
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
<Button
variant="text"
size="sm"
onPress={() => setIsErrorDialogOpen('')}
>
{t('alert.button')}
</Button>
</Dialog>
</Div>
)
}
@@ -1,4 +1,4 @@
import { A, Button, Div, H, Text } from '@/primitives'
import { A, Button, Dialog, Div, H, LinkButton, P, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
@@ -6,12 +6,15 @@ import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasRecordingAccess,
useIsRecordingTransitioning,
useStartRecording,
useStopRecording,
useHasFeatureWithoutAdminRights,
useHumanizeRecordingMaxDuration,
useRecordingStatuses,
} from '../index'
import { useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import {
NotificationType,
@@ -19,35 +22,23 @@ import {
notifyRecordingSaveInProgress,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import { Spinner } from '@/primitives/Spinner'
import { useConfig } from '@/api/useConfig'
import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox.tsx'
import {
useSettingsDialog,
SettingsDialogExtendedKey,
useTranscriptionLanguage,
} from '@/features/settings'
import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
import { useMutateRecording } from '../hooks/useMutateRecording'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
export const TranscriptSidePanel = () => {
const { data } = useConfig()
const recordingMaxDuration = useHumanizeRecordingMaxDuration()
const keyPrefix = 'transcript'
const { t } = useTranslation('rooms', { keyPrefix })
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const [includeScreenRecording, setIncludeScreenRecording] = useState(false)
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
const recordingSnap = useSnapshot(recordingStore)
const { notifyParticipants } = useNotifyParticipants()
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
useTranscriptionLanguage()
const { openSettingsDialog } = useSettingsDialog()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
@@ -61,20 +52,51 @@ export const TranscriptSidePanel = () => {
const roomId = useRoomId()
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
useMutateRecording()
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
useStartRecording({
onError: () => setIsErrorDialogOpen('start'),
})
const statuses = useRecordingStatuses(RecordingMode.Transcript)
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
useStopRecording({
onError: () => setIsErrorDialogOpen('stop'),
})
const statuses = useMemo(() => {
return {
isAnotherModeStarted:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
isStarting: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING,
isStarted: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
isStopping: recordingSnap.status == RecordingStatus.TRANSCRIPT_STOPPING,
}
}, [recordingSnap])
const isRecordingTransitioning = useIsRecordingTransitioning()
const room = useRoomContext()
const { openScreenRecording } = useSidePanel()
const isRoomConnected = room.state == ConnectionState.Connected
const handleRequestTranscription = async () => {
await notifyParticipants({
type: NotificationType.TranscriptionRequested,
})
posthog.capture('transcript-requested', {})
}
// Focus management: focus the primary action button when this side panel opens.
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => {
if (primaryActionRef.current) {
requestAnimationFrame(() => {
primaryActionRef.current?.focus({ preventScroll: true })
})
}
}, [])
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleTranscript = async () => {
if (!roomId) {
@@ -82,10 +104,10 @@ export const TranscriptSidePanel = () => {
return
}
try {
if (statuses.isStarted || statuses.isStarting) {
await stopRecording({ id: roomId })
setIncludeScreenRecording(false)
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
await notifyParticipants({
type: NotificationType.TranscriptionStopped,
})
@@ -94,64 +116,27 @@ export const TranscriptSidePanel = () => {
room.localParticipant
)
} else {
const recordingMode = includeScreenRecording
? RecordingMode.ScreenRecording
: RecordingMode.Transcript
const recordingOptions = {
...(!isLanguageSetToAuto && {
language: selectedLanguageKey,
}),
...(includeScreenRecording && {
transcribe: true,
original_mode: RecordingMode.Transcript,
}),
}
await startRecording({
id: roomId,
mode: recordingMode,
options: recordingOptions,
})
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
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)
setIsLoading(false)
}
}
if (hasFeatureWithoutAdminRights) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
i18nKey="notAdminOrOwner"
helpArticle={data?.support?.help_article_transcript}
imagePath="/assets/intro-slider/3.png"
handleRequest={handleRequestTranscription}
isActive={statuses.isActive}
/>
)
}
if (!hasTranscriptAccess) {
return (
<NoAccessView
i18nKeyPrefix={keyPrefix}
i18nKey="premium"
helpArticle={data?.support?.help_article_transcript}
imagePath="/assets/intro-slider/3.png"
handleRequest={handleRequestTranscription}
isActive={statuses.isActive}
/>
)
}
const isDisabled = useMemo(
() =>
isLoading ||
isRecordingTransitioning ||
statuses.isAnotherModeStarted ||
!isRoomConnected,
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
)
return (
<Div
@@ -164,101 +149,201 @@ export const TranscriptSidePanel = () => {
>
<img
src="/assets/intro-slider/3.png"
alt=""
alt={''}
className={css({
minHeight: '250px',
height: '250px',
minHeight: '309px',
marginBottom: '1rem',
marginTop: '-16px',
'@media (max-height: 900px)': {
height: 'auto',
minHeight: 'auto',
maxHeight: '25%',
marginBottom: '0.75rem',
},
'@media (max-height: 770px)': {
display: 'none',
},
})}
/>
<VStack gap={0} marginBottom={15}>
<H lvl={1} margin={'sm'}>
{t('heading')}
</H>
<Text variant="body" fullWidth>
{recordingMaxDuration
? t('body', {
max_duration: recordingMaxDuration,
})
: t('bodyWithoutMaxDuration')}{' '}
{data?.support?.help_article_transcript && (
<A href={data.support.help_article_transcript} target="_blank">
{t('linkMore')}
</A>
)}
</Text>
</VStack>
<VStack gap={0} marginBottom={25}>
<RowWrapper iconName="article" position="first">
<Text variant="sm">
{data?.transcription_destination ? (
<>
{t('details.destination')}{' '}
<A
href={data.transcription_destination}
{!hasTranscriptAccess ? (
<>
{hasFeatureWithoutAdminRights ? (
<>
<Text>{t('notAdminOrOwner.heading')}</Text>
<Text
variant="note"
wrap="balance"
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('notAdminOrOwner.body')}
<br />
{data?.support?.help_article_transcript && (
<A
href={data.support.help_article_transcript}
target="_blank"
>
{t('notAdminOrOwner.linkMore')}
</A>
)}
</Text>
</>
) : (
<>
<Text>{t('beta.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('beta.body')}{' '}
{data?.support?.help_article_transcript && (
<A
href={data.support.help_article_transcript}
target="_blank"
>
{t('start.linkMore')}
</A>
)}
</Text>
{data?.transcript.form_beta_users && (
<LinkButton
size="sm"
variant="tertiary"
href={data?.transcript.form_beta_users}
target="_blank"
rel="noopener noreferrer"
>
{data.transcription_destination.replace('https://', '')}
</A>
</>
) : (
t('details.destinationUnknown')
)}
</Text>
</RowWrapper>
<RowWrapper iconName="mail">
<Text variant="sm">{t('details.receiver')}</Text>
</RowWrapper>
<RowWrapper iconName="language" position="last">
<Text variant="sm">{t('details.language')}</Text>
<Text variant="sm">
<Button
variant="text"
size="xs"
onPress={() =>
openSettingsDialog(SettingsDialogExtendedKey.TRANSCRIPTION)
}
>
{selectedLanguageLabel}
</Button>
</Text>
</RowWrapper>
<div className={css({ height: '15px' })} />
<div
className={css({
width: '100%',
marginLeft: '20px',
})}
{t('beta.button')}
</LinkButton>
)}
</>
)}
</>
) : (
<>
{statuses.isStarted ? (
<>
<H lvl={3} margin={false}>
{t('stop.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stop.body')}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="stop-transcript"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
{statuses.isStopping || isPendingToStop ? (
<>
<H lvl={3} margin={false}>
{t('stopping.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stopping.body')}
</Text>
<Spinner />
</>
) : (
<>
<H lvl={3} margin={false}>
{t('start.heading')}
</H>
<Text
variant="note"
wrap="balance"
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('start.body', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
max_duration: humanizeDuration(
data?.recording?.max_duration,
{
language: i18n.language,
}
),
})
: '',
})}{' '}
{data?.support?.help_article_transcript && (
<A
href={data.support.help_article_transcript}
target="_blank"
>
{t('start.linkMore')}
</A>
)}
</Text>
<Button
ref={primaryActionRef}
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="start-transcript"
size="sm"
variant="tertiary"
>
{statuses.isStarting || isPendingToStart ? (
<>
<Spinner size={20} />
{t('start.loading')}
</>
) : (
t('start.button')
)}
</Button>
</>
)}
</>
)}
</>
)}
<Dialog
isOpen={!!isErrorDialogOpen}
role="alertdialog"
aria-label={t('alert.title')}
>
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
<Button
variant="text"
size="sm"
onPress={() => setIsErrorDialogOpen('')}
>
<Checkbox
size="sm"
isSelected={includeScreenRecording}
onChange={setIncludeScreenRecording}
isDisabled={statuses.isActive || isPendingToStart}
>
<Text variant="sm">{t('details.recording')}</Text>
</Checkbox>
</div>
</VStack>
<ControlsButton
i18nKeyPrefix={keyPrefix}
handle={handleTranscript}
statuses={statuses}
isPendingToStart={isPendingToStart}
isPendingToStop={isPendingToStop}
openSidePanel={openScreenRecording}
/>
{t('alert.button')}
</Button>
</Dialog>
</Div>
)
}
@@ -1,17 +0,0 @@
import { useMemo } from 'react'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
import { useConfig } from '@/api/useConfig'
export const useHumanizeRecordingMaxDuration = () => {
const { data } = useConfig()
return useMemo(() => {
if (!data?.recording?.max_duration) return
return humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
delimiter: ' ',
})
}, [data])
}
@@ -0,0 +1,22 @@
import { useSnapshot } from 'valtio'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { RecordingMode } from '@/features/recording'
export const useIsRecordingActive = (mode: RecordingMode) => {
const recordingSnap = useSnapshot(recordingStore)
switch (mode) {
case RecordingMode.Transcript:
return [
RecordingStatus.TRANSCRIPT_STARTED,
RecordingStatus.TRANSCRIPT_STARTING,
RecordingStatus.TRANSCRIPT_STOPPING,
].includes(recordingSnap.status)
case RecordingMode.ScreenRecording:
return [
RecordingStatus.SCREEN_RECORDING_STARTED,
RecordingStatus.SCREEN_RECORDING_STARTING,
RecordingStatus.SCREEN_RECORDING_STOPPING,
].includes(recordingSnap.status)
}
}
@@ -0,0 +1,15 @@
import { useSnapshot } from 'valtio'
import { RecordingStatus, recordingStore } from '@/stores/recording'
export const useIsRecordingTransitioning = () => {
const recordingSnap = useSnapshot(recordingStore)
const transitionalStates = [
RecordingStatus.TRANSCRIPT_STARTING,
RecordingStatus.TRANSCRIPT_STOPPING,
RecordingStatus.SCREEN_RECORDING_STARTING,
RecordingStatus.SCREEN_RECORDING_STOPPING,
]
return transitionalStates.includes(recordingSnap.status)
}
@@ -1,24 +0,0 @@
import { useStartRecording, useStopRecording } from '@/features/recording'
import { recordingStore } from '@/stores/recording'
export const useMutateRecording = () => {
const { mutateAsync: startRecording, isPending: isPendingToStart } =
useStartRecording({
onError: () => {
recordingStore.isErrorDialogOpen = 'start'
},
})
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
useStopRecording({
onError: () => {
recordingStore.isErrorDialogOpen = 'stop'
},
})
return {
startRecording,
isPendingToStart,
stopRecording,
isPendingToStop,
}
}
@@ -1,62 +0,0 @@
import { RecordingMode } from '@/features/recording'
import { useRoomMetadata } from './useRoomMetadata'
import { useMemo } from 'react'
import { useIsRecording } from '@livekit/components-react'
export enum RecordingStatus {
Starting = 'starting',
Started = 'started',
Saving = 'saving',
}
const ACTIVE_STATUSES = [
RecordingStatus.Starting,
RecordingStatus.Started,
RecordingStatus.Saving,
] as const
export interface RecordingStatuses {
isAnotherModeStarted: boolean
isStarting: boolean
isStarted: boolean
isSaving: boolean
isActive: boolean
}
export const useRecordingStatuses = (
mode: RecordingMode
): RecordingStatuses => {
const metadata = useRoomMetadata()
const isRecording = useIsRecording()
return useMemo(() => {
if (metadata && metadata?.recording_mode === mode) {
return {
isAnotherModeStarted: false,
isStarting:
metadata.recording_status === RecordingStatus.Starting ||
(metadata.recording_status === RecordingStatus.Started &&
!isRecording),
isStarted:
metadata.recording_status === RecordingStatus.Started && isRecording,
isSaving: metadata.recording_status === RecordingStatus.Saving,
isActive: ACTIVE_STATUSES.includes(
metadata.recording_status as RecordingStatus
),
}
}
const isAnotherModeStarted =
!!metadata?.recording_mode &&
metadata?.recording_mode !== mode &&
ACTIVE_STATUSES.includes(metadata.recording_status as RecordingStatus)
return {
isAnotherModeStarted,
isStarting: false,
isStarted: false,
isSaving: false,
isActive: false,
}
}, [mode, metadata, isRecording])
}
@@ -1,18 +0,0 @@
import { useRoomInfo } from '@livekit/components-react'
import { useMemo } from 'react'
export const useRoomMetadata = () => {
const { metadata } = useRoomInfo()
return useMemo(() => {
if (metadata) {
try {
return JSON.parse(metadata)
} catch (error) {
console.error('Failed to parse room metadata:', error)
return undefined
}
} else {
return undefined
}
}, [metadata])
}
+3 -3
View File
@@ -1,9 +1,9 @@
// hooks
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
export { useIsRecordingTransitioning } from './hooks/useIsRecordingTransitioning'
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
export { useIsRecordingActive } from './hooks/useIsRecordingActive'
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
export { useHumanizeRecordingMaxDuration } from './hooks/useHumanizeRecordingMaxDuration'
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
// api
export { useStartRecording } from './api/startRecording'
@@ -11,7 +11,7 @@ export { useStopRecording } from './api/stopRecording'
export { RecordingMode, RecordingStatus } from './types'
// components
export { RecordingProvider } from './components/RecordingProvider'
export { RecordingStateToast } from './components/RecordingStateToast'
export { TranscriptSidePanel } from './components/TranscriptSidePanel'
export { ScreenRecordingSidePanel } from './components/ScreenRecordingSidePanel'
@@ -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) => {
@@ -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)
@@ -16,7 +16,6 @@ import { Info } from './Info'
type StyledSidePanelProps = {
title: string
ariaLabel: string
children: ReactNode
onClose: () => void
isClosed: boolean
@@ -27,7 +26,6 @@ type StyledSidePanelProps = {
const StyledSidePanel = ({
title,
ariaLabel,
children,
onClose,
isClosed,
@@ -35,7 +33,7 @@ const StyledSidePanel = ({
isSubmenu = false,
onBack,
}: StyledSidePanelProps) => (
<aside
<div
className={css({
borderWidth: '1px',
borderStyle: 'solid',
@@ -60,8 +58,6 @@ const StyledSidePanel = ({
style={{
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<Heading
slot="title"
@@ -78,7 +74,7 @@ const StyledSidePanel = ({
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
size={'sm'}
square
className={css({ marginRight: '0.5rem' })}
onPress={onBack}
@@ -108,7 +104,7 @@ const StyledSidePanel = ({
</Button>
</Div>
{children}
</aside>
</div>
)
type PanelProps = {
@@ -148,7 +144,6 @@ export const SidePanel = () => {
return (
<StyledSidePanel
title={t(`heading.${activeSubPanelId || activePanelId}`)}
ariaLabel={t('ariaLabel')}
onClose={() => {
layoutStore.activePanelId = null
layoutStore.activeSubPanelId = null
@@ -2,15 +2,18 @@ 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 { ReactNode, useEffect, useRef } from 'react'
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import {
useIsRecordingModeEnabled,
RecordingMode,
useHasRecordingAccess,
TranscriptSidePanel,
ScreenRecordingSidePanel,
useIsRecordingActive,
} from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
import { useConfig } from '@/api/useConfig'
export interface ToolsButtonProps {
@@ -18,6 +21,8 @@ export interface ToolsButtonProps {
title: string
description: string
onPress: () => void
isBetaFeature?: boolean
isActive?: boolean
}
const ToolButton = ({
@@ -25,6 +30,8 @@ const ToolButton = ({
title,
description,
onPress,
isBetaFeature = false,
isActive = false,
}: ToolsButtonProps) => {
return (
<RACButton
@@ -35,9 +42,9 @@ const ToolButton = ({
justifyContent: 'start',
paddingY: '0.5rem',
paddingX: '0.75rem 1.5rem',
borderRadius: '30px',
borderRadius: '5px',
gap: '1.25rem',
width: 'full',
backgroundColor: 'gray.50',
textAlign: 'start',
'&[data-hovered]': {
backgroundColor: 'primary.50',
@@ -48,47 +55,59 @@ const ToolButton = ({
>
<div
className={css({
height: '40px',
minWidth: '40px',
height: '50px',
minWidth: '50px',
borderRadius: '25px',
marginRight: '0.75rem',
backgroundColor: 'primary.800',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
background: 'primary.800',
color: 'white',
})}
>
{icon}
{isBetaFeature && (
<div
className={css({
position: 'absolute',
backgroundColor: 'primary.50',
color: 'primary.800',
fontSize: '12px',
fontWeight: 500,
borderRadius: '4px',
paddingX: '4px',
paddingBottom: '1px',
bottom: -8,
right: -8,
})}
>
BETA
</div>
)}
</div>
<div>
<Text
margin={false}
as="h3"
className={css({
display: 'flex',
gap: 0.25,
fontWeight: 'semibold',
})}
className={css({ display: 'flex', gap: 0.25 })}
>
{title}
{isActive && (
<div
className={css({
backgroundColor: 'primary.500',
height: '10px',
width: '10px',
marginTop: '5px',
borderRadius: '100%',
})}
/>
)}
</Text>
<Text as="p" variant="smNote" wrap="pretty">
{description}
</Text>
</div>
<div
className={css({
marginLeft: 'auto',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
})}
>
<span className="material-symbols">chevron_forward</span>
</div>
</RACButton>
)
}
@@ -101,25 +120,55 @@ export const Tools = () => {
// 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) => {
const toolsTriggerRef = useRef<HTMLElement | null>(null)
const prevIsToolsOpenRef = useRef(false)
useEffect(() => {
const wasOpen = prevIsToolsOpenRef.current
const isOpen = isToolsOpen
// Tools just opened
if (!wasOpen && isOpen) {
const activeEl = document.activeElement as HTMLElement | null
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "Plus d'options" button that opened the menu
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
toolsTriggerRef.current = document.querySelector<HTMLButtonElement>(
'button[aria-label="Plus d\'options"]'
)
} else {
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
toolsTriggerRef.current = activeEl
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
preventScroll: true,
})
}
// Tools just closed
if (wasOpen && !isOpen) {
const trigger = toolsTriggerRef.current
if (trigger && document.contains(trigger)) {
requestAnimationFrame(() => {
trigger.focus({ preventScroll: true })
})
}
toolsTriggerRef.current = null
}
prevIsToolsOpenRef.current = isOpen
}, [isToolsOpen])
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
const isScreenRecordingEnabled = useIsRecordingModeEnabled(
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const isScreenRecordingActive = useIsRecordingActive(
RecordingMode.ScreenRecording
)
@@ -140,7 +189,6 @@ export const Tools = () => {
flexGrow={1}
flexDirection="column"
alignItems="start"
gap={0.5}
>
<Text
variant="note"
@@ -148,8 +196,8 @@ export const Tools = () => {
className={css({
textStyle: 'sm',
paddingX: '0.75rem',
marginBottom: '1rem',
})}
margin="md"
>
{t('body')}{' '}
{data?.support?.help_article_more_tools && (
@@ -163,18 +211,22 @@ export const Tools = () => {
</Text>
{isTranscriptEnabled && (
<ToolButton
icon={<span className="material-symbols">speech_to_text</span>}
icon={<RiFileTextFill size={24} color="white" />}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
isBetaFeature
isActive={isTranscriptActive}
/>
)}
{isScreenRecordingEnabled && (
{hasScreenRecordingAccess && (
<ToolButton
icon={<span className="material-symbols">mode_standby</span>}
icon={<RiLiveFill size={24} color="white" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
isBetaFeature
isActive={isScreenRecordingActive}
/>
)}
</Div>
@@ -9,7 +9,6 @@ export const OptionsButton = () => {
return (
<Menu variant="dark">
<Button
id="room-options-trigger"
square
variant="primaryDark"
aria-label={t('options.buttonLabel')}
@@ -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 [blurStatusMessage, setBlurStatusMessage] = useState('')
const blurAnnouncementTimeout = useRef<ReturnType<typeof setTimeout> | null>(
null
)
const blurAnnouncementId = useRef(0)
useEffect(() => {
const videoElement = videoRef.current
@@ -75,78 +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 (blurAnnouncementTimeout.current) {
clearTimeout(blurAnnouncementTimeout.current)
}
},
[]
)
const announceBlurStatusMessage = (message: string) => {
blurAnnouncementId.current += 1
const currentId = blurAnnouncementId.current
if (blurAnnouncementTimeout.current) {
clearTimeout(blurAnnouncementTimeout.current)
}
// Clear the region first so screen readers drop queued announcements.
setBlurStatusMessage('')
blurAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== blurAnnouncementId.current) return
setBlurStatusMessage(message)
}, 80)
}
const clearEffect = async () => {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
}
const updateBlurStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (type !== ProcessorType.BLUR) return
let message = ''
if (wasSelectedBeforeToggle) {
message = t('blur.status.none')
} else if (options.blurRadius === BlurRadius.LIGHT) {
message = t('blur.status.light')
} else if (options.blurRadius === BlurRadius.NORMAL) {
message = t('blur.status.strong')
}
if (message) {
announceBlurStatusMessage(message)
}
}
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
@@ -172,7 +104,7 @@ export const EffectsConfiguration = ({
const processor = getProcessor()
try {
if (wasSelectedBeforeToggle) {
if (isSelected(type, options)) {
// Stop processor.
await clearEffect()
} else if (
@@ -199,8 +131,6 @@ export const EffectsConfiguration = ({
// We want to trigger onSubmit when options changes so the parent component is aware of it.
onSubmit?.(processor)
}
updateBlurStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
@@ -223,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 (
@@ -355,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">
{blurStatusMessage}
</div>
})
}
isSelected={isSelected(ProcessorType.BLUR, {
blurRadius: BlurRadius.NORMAL,
})}
data-attr="toggle-blur-normal"
>
<BlurOnStrong />
</ToggleButton>
</div>
<div
className={css({
@@ -428,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')}
@@ -26,7 +26,7 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingProvider } from '@/features/recording'
import { RecordingStateToast } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
@@ -261,7 +261,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
)}
<RoomAudioRenderer />
<ConnectionStateToast />
<RecordingProvider />
<RecordingStateToast />
<SettingsDialogProvider />
</div>
)
@@ -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', {
@@ -26,23 +24,17 @@ 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 'duplicateIdentity'
case DisconnectReason.PARTICIPANT_REMOVED:
return 'participantRemoved'
}
}, [])
const [searchParams] = useSearchParams()
return (
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<Heading>
{t(
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
)}
</Heading>
<HStack>
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
@@ -16,11 +16,9 @@ import { NotificationsTab } from './tabs/NotificationsTab'
import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab'
import { VideoTab } from './tabs/VideoTab'
import { TranscriptionTab } from './tabs/TranscriptionTab'
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'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -60,8 +58,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
const dialogEl = useRef<HTMLDivElement>(null)
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
const isAdminOrOwner = useIsAdminOrOwner()
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
<Tabs
@@ -104,13 +100,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<span className="material-symbols">speech_to_text</span>
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
</TabList>
</div>
<div className={tabPanelContainerStyle}>
@@ -122,8 +111,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
</div>
</Tabs>
</Dialog>
@@ -1,30 +0,0 @@
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { Field, H } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RecordingLanguage, recordingStore } from '@/stores/recording'
import { useSnapshot } from 'valtio'
import { useTranscriptionLanguage } from '@/features/settings'
export type TranscriptionTabProps = Pick<TabPanelProps, 'id'>
export const TranscriptionTab = ({ id }: TranscriptionTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'transcription' })
const recordingSnap = useSnapshot(recordingStore)
const { languageOptions } = useTranscriptionLanguage()
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('heading')}</H>
<Field
type="select"
label={t('language.label')}
items={languageOptions}
selectedKey={recordingSnap.language}
onSelectionChange={(lang) => {
recordingStore.language = lang as RecordingLanguage
}}
/>
</TabPanel>
)
}
@@ -1,61 +0,0 @@
import { useMemo } from 'react'
import { RecordingLanguage, recordingStore } from '@/stores/recording'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio/index'
export const useTranscriptionLanguage = () => {
const { t } = useTranslation('settings', { keyPrefix: 'transcription' })
const recordingSnap = useSnapshot(recordingStore)
const languages = useMemo(
() => [
{
key: RecordingLanguage.FRENCH,
label: t('language.options.french'),
},
{
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'),
},
],
[t]
)
const languageOptions = useMemo(() => {
return languages.map((i) => ({
key: i.key,
value: i.key,
label: i.label,
}))
}, [languages])
const { selectedLanguageKey, selectedLanguageLabel } = useMemo(() => {
const selectedLanguageLabel = languages.find(
(option) => option.key === recordingSnap.language
)?.label
const selectedLanguageKey = recordingSnap.language
return { selectedLanguageKey, selectedLanguageLabel }
}, [recordingSnap.language, languages])
return {
languageOptions,
selectedLanguageKey,
selectedLanguageLabel,
isLanguageSetToAuto: selectedLanguageKey === RecordingLanguage.AUTOMATIC,
}
}
@@ -1,7 +1,2 @@
export { SettingsButton } from './components/SettingsButton'
export { SettingsDialog } from './components/SettingsDialog'
export { useTranscriptionLanguage } from './hook/useTranscriptionLanguage'
export { useSettingsDialog } from './hook/useSettingsDialog'
export { SettingsDialogExtendedKey } from './type.ts'
@@ -4,5 +4,4 @@ export enum SettingsDialogExtendedKey {
VIDEO = 'video',
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
}
@@ -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,
])
}
+24
View File
@@ -13,6 +13,29 @@ import { LoginButton } from '@/components/LoginButton'
import { useLoginHint } from '@/hooks/useLoginHint'
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: '#E8EDFF',
color: '#0063CB',
fontSize: '12px',
fontWeight: 500,
margin: '0 0 0.9375rem 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
marginTop: { base: '10px', sm: '5px' },
})}
>
Beta
</span>
)
const Logo = () => (
<img
src="/assets/logo.svg"
@@ -135,6 +158,7 @@ export const Header = () => {
/>
<HStack gap={0}>
<Logo />
<BetaBadge />
</HStack>
</Link>
</Stack>
+1 -1
View File
@@ -6,7 +6,7 @@
"heading": "Beim Laden der Seite ist ein Fehler aufgetreten"
},
"feedback": {
"context": "Ihr Feedback prägt das Produkt!",
"context": "Produkt in Entwicklung — Ihr Feedback ist wichtig!",
"cta": "Teilen Sie uns Ihre Meinung mit - neues Fenster"
},
"forbidden": {
@@ -30,14 +30,12 @@
"transcript": {
"started": "{{name}} hat die Transkription des Treffens gestartet.",
"stopped": "{{name}} hat die Transkription des Treffens gestoppt.",
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"requested": "{{name}} will die Transkription starten."
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
},
"screenRecording": {
"started": "{{name}} hat die Aufzeichnung des Treffens gestartet.",
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt.",
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"requested": "{{name}} will die Aufnahme starten."
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
},
"recordingSave": {
"transcript": {
@@ -48,6 +46,5 @@
"message": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail an <strong>{{email}}</strong>, sobald sie fertig ist.",
"default": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail, sobald sie fertig ist."
}
},
"openMenu": "Menü öffnen"
}
}
+68 -114
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"
@@ -234,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",
@@ -276,13 +256,12 @@
}
},
"sidePanel": {
"ariaLabel": "Seitenleiste",
"heading": {
"participants": "Teilnehmer",
"effects": "Effekte",
"chat": "Nachrichten im Chat",
"transcript": "Transkribieren",
"screenRecording": "Aufzeichnen",
"transcript": "Transkription",
"screenRecording": "Aufzeichnung",
"admin": "Admin-Einstellungen",
"tools": "Weitere Tools",
"info": "Meeting-Informationen"
@@ -291,8 +270,8 @@
"participants": "Teilnehmer",
"effects": "Effekte",
"chat": "Nachrichten",
"transcript": "transkribieren",
"screenRecording": "aufzeichnen",
"transcript": "Transkription",
"screenRecording": "Aufzeichnung",
"admin": "Admin-Einstellungen",
"tools": "Weitere Tools",
"info": "Meeting-Informationen"
@@ -307,12 +286,12 @@
"moreLink": "Mehr erfahren",
"tools": {
"transcript": {
"title": "Transkribieren",
"body": "Das Gespräch aufzeichnen."
"title": "Transkription",
"body": "Führen Sie ein schriftliches Protokoll Ihres Meetings."
},
"screenRecording": {
"title": "Aufzeichnen",
"body": "Das Meeting aufzeichnen."
"title": "Aufzeichnung",
"body": "Zeichnen Sie Ihr Meeting auf, um es später erneut anzusehen."
}
}
},
@@ -331,99 +310,74 @@
}
},
"transcript": {
"heading": "Transkribieren Sie Ihr Meeting mit dem KI-Assistenten",
"body": "Transkribieren Sie bis zu {{max_duration}} Meetingdauer.",
"bodyWithoutMaxDuration": "Transkribieren Sie Ihr Meeting ohne Begrenzung.",
"details": {
"receiver": "Das Transkript wird an den Organisator und die Mitorganisatoren gesendet.",
"destination": "Ein neues Dokument wird erstellt auf",
"destinationUnknown": "Ein neues Dokument wird erstellt",
"language": "Meeting-Sprache:",
"recording": "Auch eine Aufzeichnung starten"
"start": {
"heading": "Dieses Gespräch transkribieren",
"body": "Dieses Gespräch automatisch transkribieren {{duration_message}} und die Zusammenfassung in Docs erhalten.",
"button": "Transkription starten",
"loading": "Transkription wird gestartet",
"linkMore": "Mehr erfahren"
},
"button": {
"start": "Meeting transkribieren starten",
"stop": "Meeting transkribieren beenden",
"saving": "Speichern…",
"starting": "Wird gestartet…",
"anotherModeStarted": "Eine Aufnahme läuft. <br/> Wechseln Sie das Menü und stoppen Sie sie."
},
"linkMore": "Mehr erfahren",
"notAdminOrOwner": {
"heading": "Zugriff eingeschränkt",
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine Transkription (Beta) starten.",
"linkMore": "Mehr erfahren",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Nur der Ersteller des Meetings oder ein Administrator kann die Transkription starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
},
"request": {
"heading": "Anfrage an Moderator",
"body": "Der Moderator wird benachrichtigt und kann die Transkription starten.",
"buttonLabel": "Anfordern"
}
"linkMore": "Mehr erfahren"
},
"premium": {
"heading": "Premium-Funktion",
"body": "Diese Funktion ist öffentlichen Bediensteten vorbehalten. Wenn Ihre E-Mail-Adresse nicht autorisiert ist, kontaktieren Sie bitte den Support, um Zugriff zu erhalten.",
"linkMore": "Mehr erfahren",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Nur der Ersteller des Meetings oder ein Administrator kann die Transkription starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
"stop": {
"heading": "Transkription läuft...",
"body": "Die Transkription deines Meetings läuft. Du erhältst das Ergebnis per E-Mail, sobald das Meeting beendet ist.",
"button": "Transkription stoppen"
},
"stopping": {
"heading": "Daten werden gespeichert…",
"body": "Sie können das Meeting verlassen, wenn Sie möchten; die Aufzeichnung wird automatisch beendet."
},
"beta": {
"heading": "Werde Beta-Tester",
"body": "Zeichne dein Meeting auf. Du erhältst eine Zusammenfassung per E-Mail nach dem Meeting.",
"button": "Anmelden"
},
"alert": {
"title": "Transkription fehlgeschlagen",
"body": {
"stop": "Die Transkription konnte nicht gestoppt werden. Bitte versuche es in einem Moment erneut.",
"start": "Die Transkription konnte nicht gestartet werden. Bitte versuche es in einem Moment erneut."
},
"request": {
"heading": "Anfrage an Moderator",
"body": "Der Moderator wird benachrichtigt und kann die Transkription starten.",
"buttonLabel": "Anfordern"
}
}
"button": "OK"
},
"durationMessage": "(begrenzt auf {{max_duration}}) "
},
"screenRecording": {
"heading": "Diesen Anruf für später aufzeichnen",
"body": "Zeichnen Sie bis zu {{max_duration}} des Meetings auf.",
"bodyWithoutMaxDuration": "Zeichnen Sie Ihr Meeting unbegrenzt auf.",
"linkMore": "Mehr erfahren",
"details": {
"receiver": "Die Aufzeichnung wird an den Organisator und die Mitorganisatoren gesendet.",
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
"start": {
"heading": "Dieses Gespräch aufzeichnen",
"body": "Zeichne dieses Gespräch auf, um es später anzusehen {{duration_message}}. Du erhältst die Videoaufnahme per E-Mail.",
"button": "Aufzeichnung starten",
"loading": "Aufzeichnung wird gestartet",
"linkMore": "Mehr erfahren",
"transcription": "Diese Aufzeichnung transkribieren"
},
"button": {
"start": "Meeting-Aufzeichnung starten",
"stop": "Meeting-Aufzeichnung beenden",
"saving": "Wird gespeichert…",
"starting": "Wird gestartet…",
"anotherModeStarted": "Eine Transkription läuft. <br/> Wechseln Sie das Menü und stoppen Sie sie."
"linkMore": "Mehr erfahren"
},
"notAdminOrOwner": {
"heading": "Zugriff eingeschränkt",
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine Videoaufnahme (Beta) starten.",
"linkMore": "Mehr erfahren",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Nur der Ersteller der Besprechung oder ein Administrator kann die Aufzeichnung starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine videoaufnahme (Beta) starten.",
"linkMore": "Mehr erfahren"
},
"stopping": {
"heading": "Daten werden gespeichert…",
"body": "Sie können das Meeting verlassen, wenn Sie möchten; die Aufzeichnung wird automatisch beendet."
},
"stop": {
"heading": "Aufzeichnung läuft…",
"body": "Du erhältst das Ergebnis per E-Mail, sobald die Aufzeichnung abgeschlossen ist.",
"button": "Aufzeichnung stoppen"
},
"alert": {
"title": "Aufzeichnung fehlgeschlagen",
"body": {
"stop": "Die Aufzeichnung konnte nicht gestoppt werden. Bitte versuche es in einem Moment erneut.",
"start": "Die Aufzeichnung konnte nicht gestartet werden. Bitte versuche es in einem Moment erneut."
},
"request": {
"heading": "Aufnahme anfordern",
"body": "Der Moderator wird benachrichtigt und kann die Aufnahme für Sie starten.",
"buttonLabel": "Anfrage senden"
}
"button": "OK"
},
"durationMessage": "(begrenzt auf {{max_duration}})"
},
"errorRecordingAlertDialog": {
"title": "Aufzeichnung fehlgeschlagen",
"body": {
"stop": "Die Aufzeichnung konnte nicht gestoppt werden. Bitte versuche es in einem Moment erneut.",
"start": "Die Aufzeichnung konnte nicht gestartet werden. Bitte versuche es in einem Moment erneut."
},
"button": "OK"
},
"admin": {
"description": "Diese Einstellungen für Organisatoren ermöglichen dir die Kontrolle über dein Meeting. Nur Organisatoren haben Zugriff auf diese Optionen.",
"access": {
@@ -542,7 +496,7 @@
"starting": "Transkription wird gestartet",
"stopping": "Transkription wird gestoppt"
},
"screen_recording": {
"screenRecording": {
"started": "Aufzeichnung läuft",
"starting": "Aufzeichnung wird gestartet",
"stopping": "Aufzeichnung wird gestoppt"
+1 -15
View File
@@ -68,19 +68,6 @@
},
"permissionsRequired": "Berechtigungen erforderlich"
},
"transcription": {
"heading": "Transkription",
"language": {
"label": "Besprechungssprache",
"options": {
"french": "Französisch (fr)",
"english": "Englisch (en)",
"dutch": "Niederländisch (nl)",
"german": "Deutsch (de)",
"auto": "Automatisch"
}
}
},
"notifications": {
"heading": "Tonbenachrichtigungen",
"label": "Tonbenachrichtigungen für",
@@ -113,7 +100,6 @@
"audio": "Audio",
"video": "Video",
"general": "Allgemein",
"notifications": "Benachrichtigungen",
"transcription": "Transkription"
"notifications": "Benachrichtigungen"
}
}
@@ -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."
}
}
},
+1 -1
View File
@@ -6,7 +6,7 @@
"heading": "An error occurred while loading the page"
},
"feedback": {
"context": "Your feedback shapes the product!",
"context": "Product under development — your input matters!",
"cta": "Share your feedback - new tab"
},
"forbidden": {
@@ -30,14 +30,12 @@
"transcript": {
"started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription.",
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved.",
"requested": "{{name}} wants to start the meeting transcription."
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved."
},
"screenRecording": {
"started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording.",
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved.",
"requested": "{{name}} wants to start the meeting recording."
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved."
},
"recordingSave": {
"transcript": {
@@ -48,6 +46,5 @@
"message": "Your recording is being saved! Well send a notification to <strong>{{email}}</strong> as soon as its ready.",
"default": "Your recording is being saved! Well send a notification to your email as soon as its ready."
}
},
"openMenu": "Open menu"
}
}
+74 -120
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."
},
@@ -217,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",
@@ -234,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",
@@ -276,23 +256,22 @@
}
},
"sidePanel": {
"ariaLabel": "Sidepanel",
"heading": {
"participants": "Participants",
"effects": "Backgrounds and Effects",
"effects": "Effects",
"chat": "Messages in the chat",
"transcript": "Transcribe",
"screenRecording": "Record",
"transcript": "Transcription",
"screenRecording": "Recording",
"admin": "Admin settings",
"tools": "More tools",
"info": "Meeting information"
},
"content": {
"participants": "participants",
"effects": "Backgrounds and Effects",
"effects": "effects",
"chat": "messages",
"transcript": "transcribe",
"screenRecording": "record",
"transcript": "transcription",
"screenRecording": "recording",
"admin": "admin settings",
"tools": "more tools",
"info": "meeting information"
@@ -307,12 +286,12 @@
"moreLink": "Learn more",
"tools": {
"transcript": {
"title": "Transcribe",
"body": "Record the conversation."
"title": "Transcription",
"body": "Keep a written record of your meeting."
},
"screenRecording": {
"title": "Record",
"body": "Record the meeting."
"title": "Recording",
"body": "Record your meeting to watch it again whenever you like."
}
}
},
@@ -331,99 +310,74 @@
}
},
"transcript": {
"heading": "Transcribe your meeting with the AI Assistant",
"body": "Transcribe up to {{max_duration}} of meeting time.",
"bodyWithoutMaxDuration": "Transcribe your meeting without limits.",
"details": {
"receiver": "The transcript will be sent to the host and co-hosts.",
"destination": "A new document will be created on",
"destinationUnknown": "A new document will be created",
"language": "Meeting language:",
"recording": "Also start a recording"
"start": {
"heading": "Transcribe this call",
"body": "Automatically transcribe this call {{duration_message}} and receive the summary in Docs.",
"button": "Start transcription",
"loading": "Transcription starting",
"linkMore": "Learn more"
},
"button": {
"start": "Start transcribing the meeting",
"stop": "Stop transcribing the meeting",
"saving": "Saving…",
"starting": "Starting…",
"anotherModeStarted": "Screen recording is running. Switch panels and stop it."
},
"linkMore": "Learn more",
"notAdminOrOwner": {
"heading": "Restricted Access",
"body": "For security reasons, only the meeting creator or an admin can start a transcription (beta).",
"linkMore": "Learn more",
"dividerLabel": "OR",
"login": {
"heading": "Login Required",
"body": "Only the meeting creator or an admin can start a transcription. Log in to verify your permissions."
},
"request": {
"heading": "Request Transcription",
"body": "The host will be notified and can enable transcription for you.",
"buttonLabel": "Request"
}
"linkMore": "Learn more"
},
"premium": {
"heading": "Premium feature",
"body": "This feature is reserved for public agents. If your email address is not authorized, please contact support to get access.",
"linkMore": "Learn more",
"dividerLabel": "OR",
"login": {
"heading": "You are not logged in!",
"body": "You must be logged in to use this feature. Please log in, then try again."
"stop": {
"heading": "Transcription in progress...",
"body": "The transcription of your meeting is in progress. You will receive the result by email once the meeting is finished.",
"button": "Stop transcription"
},
"stopping": {
"heading": "Saving your data…",
"body": "You can leave the meeting if you wish; the recording will finish automatically."
},
"beta": {
"heading": "Become a beta tester",
"body": "Record your meeting for later. You will receive a summary by email once the meeting is finished.",
"button": "Sign up"
},
"alert": {
"title": "Transcription Failed",
"body": {
"stop": "We were unable to stop the transcription. Please try again in a moment.",
"start": "We were unable to start the transcription. Please try again in a moment."
},
"request": {
"heading": "Request Transcription",
"body": "The host will be notified and can enable transcription for you.",
"buttonLabel": "Request"
}
}
"button": "OK"
},
"durationMessage": "(limited to {{max_duration}}) "
},
"screenRecording": {
"heading": "Record this call for later",
"body": "Record up to {{max_duration}} of meeting.",
"bodyWithoutMaxDuration": "Record your meeting without limit.",
"linkMore": "Learn more",
"details": {
"receiver": "The recording will be sent to the host and co-hosts.",
"destination": "This recording will be temporarily stored on our servers",
"loading": "Starting recording",
"linkMore": "Learn more",
"transcription": "Transcribe this recording"
},
"button": {
"start": "Start recording the meeting",
"stop": "Stop recording the meeting",
"saving": "Saving…",
"starting": "Starting…",
"anotherModeStarted": "Transcription is running. <br/> Switch panels and stop it."
"start": {
"heading": "Record this call",
"body": "Record this call to watch it later {{duration_message}} and receive the video recording by email.",
"button": "Start recording",
"loading": "Recording starting",
"linkMore": "Learn more"
},
"notAdminOrOwner": {
"heading": "Restricted Access",
"body": "For security reasons, only the meeting creator or an admin can start a recording (beta).",
"linkMore": "Learn more",
"dividerLabel": "OR",
"login": {
"heading": "Login Required",
"body": "Only the meeting creator or an admin can start screen recording. Log in to verify your permissions."
"linkMore": "Learn more"
},
"stopping": {
"heading": "Saving your data…",
"body": "You can leave the meeting if you wish; the recording will finish automatically."
},
"stop": {
"heading": "Recording in progress…",
"body": "You will receive the result by email once the recording is complete.",
"button": "Stop recording"
},
"alert": {
"title": "Recording Failed",
"body": {
"stop": "We were unable to stop the recording. Please try again in a moment.",
"start": "We were unable to start the recording. Please try again in a moment."
},
"request": {
"heading": "Request Recording",
"body": "The host will be notified and can enable recording for you.",
"buttonLabel": "Request"
}
"button": "OK"
},
"durationMessage": "(limited to {{max_duration}}) "
},
"errorRecordingAlertDialog": {
"title": "Recording Failed",
"body": {
"stop": "We were unable to stop the recording. Please try again in a moment.",
"start": "We were unable to start the recording. Please try again in a moment."
},
"button": "OK"
},
"admin": {
"description": "These organizer settings allow you to maintain control of your meeting. Only organizers can access these controls.",
"access": {
@@ -542,7 +496,7 @@
"starting": "Transcription starting",
"stopping": "Transcription stopping"
},
"screen_recording": {
"screenRecording": {
"started": "Recording in progress",
"starting": "Starting recording",
"stopping": "Stopping recording"
@@ -564,7 +518,7 @@
"enable": "Pin",
"disable": "Unpin"
},
"effects": "Apply backgrounds and effects",
"effects": "Apply visual effects",
"muteParticipant": "Mute {{name}}",
"fullScreen": "Full screen"
},
+1 -15
View File
@@ -68,19 +68,6 @@
},
"permissionsRequired": "Permissions required"
},
"transcription": {
"heading": "Transcription",
"language": {
"label": "Meeting language",
"options": {
"french": "French (fr)",
"english": "English (en)",
"dutch": "Dutch (nl)",
"german": "German (de)",
"auto": "Automatic"
}
}
},
"notifications": {
"heading": "Sound notifications",
"label": "sound notifications for",
@@ -113,7 +100,6 @@
"audio": "Audio",
"video": "Video",
"general": "General",
"notifications": "Notifications",
"transcription": "Transcription"
"notifications": "Notifications"
}
}
@@ -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."
}
}
},
+1 -1
View File
@@ -6,7 +6,7 @@
"heading": "Une erreur est survenue lors du chargement de la page"
},
"feedback": {
"context": "Vos retours sont essentiels !",
"context": "Produit en cours de développement — votre avis compte !",
"cta": "Partagez votre avis - nouvelle fenêtre"
},
"forbidden": {
@@ -30,14 +30,12 @@
"transcript": {
"started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion.",
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée.",
"requested": "{{name}} a demandé à démarrer la transcription."
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée."
},
"screenRecording": {
"started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé.",
"requested": "{{name}} a demandé à démarrer l'enregistrement."
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé."
},
"recordingSave": {
"transcript": {
@@ -48,6 +46,5 @@
"message": "Nous finalisons votre enregistrement ! Vous recevrez un e-mail à <strong>{{email}}</strong> dès quil sera prêt.",
"default": "Nous finalisons votre enregistrement ! Vous recevrez un e-mail dès quil sera prêt."
}
},
"openMenu": "Ouvrir le menu"
}
}

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