Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine af214d582d 🐛(frontend) fix unclickable fullscreen warning buttons
Adjust z-index values to restore button interactivity broken by previous
z-index changes in commit 53e68b7, ensuring fullscreen warning dismiss
controls remain accessible to users instead of being blocked
by overlay layering.
2025-12-10 12:44:57 +01:00
153 changed files with 1909 additions and 4627 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
-26
View File
@@ -34,32 +34,6 @@ jobs:
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
build-mails:
runs-on: ubuntu-latest
defaults:
+2 -54
View File
@@ -1,3 +1,4 @@
# Changelog
All notable changes to this project will be documented in this file.
@@ -8,57 +9,4 @@ 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
- 🩹(frontend) icon font loading to avoid text/icon flickering
## [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
- ♿(frontend) improve accessibility:
- ♿️(frontend) hover controls, focus, SR #803
- ♿️(frontend) change ptt keybinding from space to v #813
- ♿(frontend) indicate external link opens in new window on feedback #816
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
- ♿️(frontend) Improve focus management when opening and closing chat #807
- 🔧(backend) support `_FILE` for secret environment variables #566
-160
View File
@@ -1,160 +0,0 @@
#!/bin/bash
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
# Function to update npm package version
update_npm_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
npm version "$VERSION" --no-git-tag-version
cd -
}
# Function to update Python project version in pyproject.toml
update_python_version() {
local component=$1
print_info "Updating $component version..."
cd "src/$component"
if [ ! -f "pyproject.toml" ]; then
print_error "pyproject.toml not found in src/$component!"
exit 1
fi
if grep -q '^version = "' pyproject.toml; then
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml
rm pyproject.toml.bak
print_info "Updated pyproject.toml version to $VERSION"
else
print_error "Could not find version line in pyproject.toml"
exit 1
fi
cd -
}
# Check if we're in a git repository
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not a git repository. Please run this script from the root of your project."
exit 1
fi
# Check if working directory is clean
if ! git diff-index --quiet HEAD --; then
print_error "Working directory is not clean. Please commit or stash your changes first."
exit 1
fi
# Ask user for release version number
echo ""
read -p "Enter release version number (e.g., 1.2.3): " VERSION
# Validate version format (basic semver check)
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
print_error "Invalid version format. Please use semantic versioning (e.g., 1.2.3)"
exit 1
fi
print_info "Release version: $VERSION"
# Check if branch already exists
BRANCH_NAME="release/$VERSION"
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
print_error "Branch $BRANCH_NAME already exists!"
exit 1
fi
# Create and checkout new branch
print_info "Creating branch: $BRANCH_NAME"
git checkout -b "$BRANCH_NAME"
# Update frontend
update_npm_version "frontend"
# Update SDK
update_npm_version "sdk"
# Update mail
update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Update summary pyproject.toml
update_python_version "summary"
# Update agents pyproject.toml
update_python_version "agents"
# Update CHANGELOG
print_info "Updating CHANGELOG..."
if [ ! -f "CHANGELOG.md" ]; then
print_error "CHANGELOG.md not found in project root!"
exit 1
fi
# Get current date in YYYY-MM-DD format
CURRENT_DATE=$(date +%Y-%m-%d)
# Replace [Unreleased] with [version number] - YYYY-MM-DD
if grep -q '\[Unreleased\]' CHANGELOG.md; then
sed -i.bak "s/\[Unreleased\]/[$VERSION] - $CURRENT_DATE/" CHANGELOG.md
# Add new [Unreleased] section after the header
# This adds it after the line containing "Semantic Versioning"
sed -i.bak "/Semantic Versioning/a\\
\\
## [Unreleased]
" CHANGELOG.md
rm CHANGELOG.md.bak
print_info "Updated CHANGELOG.md"
else
print_warning "Could not find [Unreleased] section in CHANGELOG.md"
fi
# Summary
echo ""
print_info "Release preparation complete!"
echo ""
echo "Summary:"
echo " - Branch created: $BRANCH_NAME"
echo " - Version updated to: $VERSION"
echo " - Files modified:"
echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
echo ""
print_warning "Next steps:"
echo " 1. Review the changes: git status"
echo " 2. Commit the changes: git add . && git commit -m 'Release $VERSION'"
echo " 3. Push the branch: git push origin $BRANCH_NAME"
echo ""
+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 = "0.1.42"
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"
]
-8
View File
@@ -159,8 +159,6 @@ class RoomSerializer(serializers.ModelSerializer):
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
else:
del output["pin_code"]
output["is_administrable"] = is_admin_or_owner
@@ -181,7 +179,6 @@ class RecordingSerializer(serializers.ModelSerializer):
"updated_at",
"status",
"mode",
"options",
"key",
"is_expired",
"expired_at",
@@ -213,11 +210,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
@@ -3,8 +3,6 @@
import logging
from typing import Dict
from django.conf import settings
from rest_framework import exceptions, permissions
from .. import models
@@ -57,12 +55,6 @@ class BaseScopePermission(permissions.BasePermission):
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
if settings.OIDC_RS_SCOPES_PREFIX:
token_scopes = [
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
for scope in token_scopes
]
if required_scope not in token_scopes:
raise exceptions.PermissionDenied(
f"Insufficient permissions. Required scope: {required_scope}"
+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"""
@@ -32,6 +32,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -51,6 +52,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -69,6 +71,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -85,6 +88,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -101,6 +105,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -342,6 +347,7 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -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"
@@ -373,7 +373,6 @@ def test_resource_server_creates_user_on_first_authentication(settings):
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
@@ -390,7 +389,7 @@ def test_resource_server_creates_user_on_first_authentication(settings):
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"scope": "openid lasuite_meet rooms:list",
"active": True,
},
)
@@ -490,7 +489,6 @@ def test_resource_server_authentication_successful(settings):
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
@@ -507,7 +505,7 @@ def test_resource_server_authentication_successful(settings):
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"scope": "openid lasuite_meet rooms:list",
"active": True,
},
)
@@ -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 -21
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
@@ -542,9 +538,6 @@ class Base(Configuration):
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
default="RSA", environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE", environ_prefix=None
)
OIDC_RS_SCOPES_PREFIX = values.Value(
default=None, environ_name="OIDC_RS_SCOPES_PREFIX", environ_prefix=None
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
@@ -634,9 +627,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 +767,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 = "0.1.42"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -56,7 +56,7 @@ dependencies = [
"whitenoise==6.11.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.7",
"aiohttp==3.13.3",
"aiohttp==3.13.2",
]
[project.urls]
-16
View File
@@ -6,22 +6,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
type="font/woff2"
/>
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
type="font/woff2"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
+24 -36
View File
@@ -1,15 +1,13 @@
{
"name": "meet",
"version": "1.2.0",
"version": "0.1.42",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.2.0",
"version": "0.1.42",
"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",
@@ -57,7 +55,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.0.8",
"vite": "7.0.2",
"vite-tsconfig-paths": "5.1.4"
}
},
@@ -1020,24 +1018,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",
@@ -3838,6 +3818,17 @@
"win32"
]
},
"node_modules/@rrweb/types": {
"version": "2.0.0-alpha.17",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.17.tgz",
"integrity": "sha512-AfDTVUuCyCaIG0lTSqYtrZqJX39ZEYzs4fYKnexhQ+id+kbZIpIJtaut5cto6dWZbB3SEe4fW0o90Po3LvTmfg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"rrweb-snapshot": "^2.0.0-alpha.17"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz",
@@ -9742,15 +9733,15 @@
}
},
"node_modules/vite": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.8.tgz",
"integrity": "sha512-cJBdq0/u+8rgstg9t7UkBilf8ipLmeXJO30NxD5HAHOivnj10ocV8YtR/XBvd2wQpN3TmcaxNKaHX3tN7o5F5A==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.2.tgz",
"integrity": "sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.6",
"picomatch": "^4.0.3",
"picomatch": "^4.0.2",
"postcss": "^8.5.6",
"rollup": "^4.40.0",
"tinyglobby": "^0.2.14"
@@ -9858,14 +9849,11 @@
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -9876,9 +9864,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"engines": {
+2 -4
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.2.0",
"version": "0.1.42",
"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",
@@ -62,7 +60,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.0.8",
"vite": "7.0.2",
"vite-tsconfig-paths": "5.1.4"
}
}
+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> => {
-1
View File
@@ -68,7 +68,6 @@ export const Avatar = ({
{...props}
>
<span
aria-hidden="true"
className={css({
marginTop: '-0.3rem',
})}
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,175 +0,0 @@
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner'
import { Button, Icon, 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()}
>
<Icon
className={css({
color: 'primary.500',
marginRight: '1rem',
})}
name="info"
/>
<Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))}
</Text>
<Icon
className={css({
color: 'primary.500',
marginLeft: 'auto',
})}
name="chevron_right"
/>
</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,44 +0,0 @@
import { H, Text, Icon } 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">
<Icon type="symbols" name="login" />
<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,167 +1,210 @@
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, 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
}
}, [recordingSnap])
if (!isStarting && !isStarted) {
return undefined
}
if (!key)
return isAdminOrOwner ? (
<LimitReachedAlertDialog
isOpen={isAlertOpen}
onClose={() => setIsAlertOpen(false)}
aria-label="Recording limit exceeded"
/>
) : null
let status = metadata.recording_status
if (isStarted && !isRecording) {
status = 'starting'
}
return `${metadata.recording_mode}.${status}`
}, [metadata, isStarted, isStarting, isRecording])
// Update screen reader message only when the key actually changes
// This prevents duplicate announcements caused by re-renders
useEffect(() => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
}
}, [key, t])
if (!key) return null
const isStarted = key?.includes('started')
const hasScreenRecordingAccessAndActive =
isScreenRecordingActive && hasScreenRecordingAccess
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
return (
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
<RecordingStatusIcon
isStarted={isStarted}
isTranscriptActive={isTranscriptActive}
<div
className={css({
display: 'flex',
position: 'fixed',
top: '10px',
left: '10px',
paddingY: '0.25rem',
paddingX: '0.75rem 0.75rem',
backgroundColor: 'danger.700',
borderColor: 'white',
border: '1px solid',
color: 'white',
borderRadius: '4px',
gap: '0.5rem',
})}
>
{isStarted ? (
<RiRecordCircleLine
size={20}
className={css({
animation: 'pulse_background 1s infinite',
})}
/>
) : (
<Spinner size={20} variant="dark" />
)}
{!hasScreenRecordingAccessAndActive &&
!hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
</>
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
<Text
variant={'sm'}
className={css({
fontWeight: '500 !important',
})}
>
{t(key)}
</Text>
)}
{hasScreenRecordingAccessAndActive && (
<RACButton
onPress={openScreenRecording}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
{hasTranscriptAccessAndActive && (
<RACButton
onPress={openTranscript}
className={css({
textStyle: 'sm !important',
fontWeight: '500 !important',
cursor: 'pointer',
})}
>
{t(key)}
</RACButton>
)}
</div>
)
}
@@ -1,22 +0,0 @@
import { Spinner } from '@/primitives/Spinner'
import { Icon } from '@/primitives'
interface RecordingStatusIconProps {
isStarted: boolean
isTranscriptActive: boolean
}
export const RecordingStatusIcon = ({
isStarted,
isTranscriptActive,
}: RecordingStatusIconProps) => {
if (!isStarted) {
return <Spinner size={20} variant="dark" />
}
if (isTranscriptActive) {
return <Icon type="symbols" name="speech_to_text" />
}
return <Icon type="symbols" name="screen_record" />
}
@@ -1,87 +0,0 @@
import { Button, Icon, 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">
<Icon type="symbols" name="person_raised_hand" />
<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,64 +0,0 @@
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
import { Icon } from '@/primitives'
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 */}
<Icon name={iconName} />
</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, 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,59 @@ 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,
})
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 +80,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 +92,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 +128,130 @@ 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
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
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, 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,40 @@ 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', {})
}
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleTranscript = async () => {
if (!roomId) {
@@ -82,10 +93,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 +105,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 +138,199 @@ 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
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
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,11 @@ 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,
@@ -170,8 +169,6 @@ export const Conference = ({
kind: null,
})
const isMobile = useIsMobile()
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
@@ -228,20 +225,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) => {
@@ -251,7 +238,7 @@ export const Conference = ({
}}
>
<VideoConference />
{showInviteDialog && !isMobile && (
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
@@ -67,7 +67,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
gap={0}
style={{ maxWidth: '100%', overflow: 'hidden' }}
>
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
{t('heading')}
</Heading>
<Div position="absolute" top="5" right="5">
@@ -1,32 +0,0 @@
import React, { ReactNode } from 'react'
import { css } from '@/styled-system/css'
export interface KeyboardShortcutHintProps {
children: ReactNode
}
/**
* Small reusable bubble used to display and announce keyboard shortcuts,
* typically when an element receives keyboard focus.
*/
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
children,
}) => {
return (
<div
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
paddingInline: '0.5rem',
paddingBlock: '0.1rem',
fontSize: '0.875rem',
})}
>
{children}
</div>
)
}
@@ -35,7 +35,6 @@ export const ParticipantName = ({
style={{
paddingBottom: '0.1rem',
}}
aria-hidden="true"
>
{displayedName}
</Text>
@@ -29,9 +29,6 @@ import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -105,33 +102,9 @@ export const ParticipantTile: (
})
const isScreenShare = trackReference.source != Track.Source.Camera
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
const participantName = getParticipantName(trackReference.participant)
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
const interactiveProps = {
...elementProps,
// Ensure the tile is focusable to expose contextual controls to keyboard users.
tabIndex: 0,
'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)
},
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
elementProps.onBlur?.(event)
const nextTarget = event.relatedTarget as Node | null
if (!event.currentTarget.contains(nextTarget)) {
setHasKeyboardFocus(false)
}
},
}
return (
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
<FullScreenShareWarning trackReference={trackReference} />
@@ -222,16 +195,10 @@ export const ParticipantTile: (
</>
)}
{!disableMetadata && (
<ParticipantTileFocus
trackRef={trackReference}
hasKeyboardFocus={hasKeyboardFocus}
/>
<ParticipantTileFocus trackRef={trackReference} />
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
</div>
)
})
@@ -131,10 +131,8 @@ const MOUSE_IDLE_TIME = 3000
export const ParticipantTileFocus = ({
trackRef,
hasKeyboardFocus,
}: {
trackRef: TrackReferenceOrPlaceholder
hasKeyboardFocus: boolean
}) => {
const [hovered, setHovered] = useState(false)
const [opacity, setOpacity] = useState(0)
@@ -142,10 +140,8 @@ export const ParticipantTileFocus = ({
const idleTimerRef = useRef<number | null>(null)
const [isIdleRef, setIsIdleRef] = useState(false)
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
useEffect(() => {
if (isVisible) {
if (hovered && !isIdleRef) {
// Wait for next frame to ensure element is mounted
requestAnimationFrame(() => {
setOpacity(0.6)
@@ -153,7 +149,7 @@ export const ParticipantTileFocus = ({
} else {
setOpacity(0)
}
}, [isVisible])
}, [hovered, isIdleRef])
const handleMouseMove = () => {
if (idleTimerRef.current) {
@@ -184,12 +180,11 @@ export const ParticipantTileFocus = ({
width: '100%',
height: '100%',
})}
aria-hidden={!isVisible}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseMove={handleMouseMove}
>
{isVisible && (
{hovered && (
<div
className={css({
backgroundColor: 'primaryDark.50',
@@ -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
@@ -169,7 +164,7 @@ export const SidePanel = () => {
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Panel isOpen={isToolsOpen}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen}>
@@ -1,16 +1,19 @@
import { A, Div, Icon, Text } from '@/primitives'
import { A, Div, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { ReactNode } from 'react'
import { 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,78 +55,81 @@ 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',
})}
>
<Icon type="symbols" name="chevron_forward" />
</div>
</RACButton>
)
}
export const Tools = () => {
const { data } = useConfig()
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
// Restore focus to the element that opened the Tools panel
// following the same pattern as Chat.
useRestoreFocus(isToolsOpen, {
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
// find the "more options" button ("Plus d'options") that opened the menu
resolveTrigger: (activeEl) => {
if (activeEl?.tagName === 'DIV') {
return document.querySelector<HTMLElement>('#room-options-trigger')
}
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
},
restoreFocusRaf: true,
preventScroll: true,
})
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
const isScreenRecordingEnabled = useIsRecordingModeEnabled(
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
const isScreenRecordingActive = useIsRecordingActive(
RecordingMode.ScreenRecording
)
@@ -140,7 +150,6 @@ export const Tools = () => {
flexGrow={1}
flexDirection="column"
alignItems="start"
gap={0.5}
>
<Text
variant="note"
@@ -148,8 +157,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 +172,22 @@ export const Tools = () => {
</Text>
{isTranscriptEnabled && (
<ToolButton
icon={<Icon type="symbols" name="speech_to_text" />}
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={<Icon type="symbols" name="mode_standby" />}
icon={<RiLiveFill size={24} color="white" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
isBetaFeature
isActive={isScreenRecordingActive}
/>
)}
</Div>
@@ -93,7 +93,7 @@ export const ToggleDevice = <T extends ToggleSource>({
isDisabled: cannotUseDevice,
})
useLongPress({
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
keyCode: kind === 'audioinput' ? 'Space' : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
@@ -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 * as React 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>,
@@ -37,19 +36,6 @@ export function Chat({ ...props }: ChatProps) {
const { isChatOpen } = useSidePanel()
const chatSnap = useSnapshot(chatStore)
// 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: () => {
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true })
})
},
preventScroll: true,
})
// Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants()
@@ -59,7 +45,7 @@ export function Chat({ ...props }: ChatProps) {
async function handleSubmit(text: string) {
if (!send || !text) return
await send(text)
inputRef?.current?.focus({ preventScroll: true })
inputRef?.current?.focus()
}
// TEMPORARY: This is a brittle workaround that relies on message count tracking
@@ -11,7 +11,6 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
@@ -20,18 +19,6 @@ export function DesktopControlBar({
}: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
useRegisterKeyboardShortcut({
shortcut: { key: 'F2' },
handler: () => {
const root = desktopControlBarEl.current
if (!root) return
const firstButton = root.querySelector<HTMLButtonElement>(
'button, [role="button"], [tabindex="0"]'
)
firstButton?.focus()
},
})
return (
<div
ref={desktopControlBarEl}
@@ -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', {
@@ -22,40 +20,25 @@ const Heading = styled('h1', {
},
})
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
}
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
const [, setLocation] = useLocation()
const reasonKey = useMemo(() => {
const state = window.history.state
if (!state?.reason) return
switch (state.reason) {
case DisconnectReason.DUPLICATE_IDENTITY:
return DisconnectReasonKey.DuplicateIdentity
case DisconnectReason.PARTICIPANT_REMOVED:
return DisconnectReasonKey.ParticipantRemoved
}
}, [])
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
const [searchParams] = useSearchParams()
return (
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<HStack>
{showBackButton && (
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
<Heading>
{t(
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
)}
</Heading>
<HStack>
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
<Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')}
</Button>
@@ -2,7 +2,6 @@ import { Dialog, type DialogProps } from '@/primitives'
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx'
import { Icon } from '@/primitives/Icon'
import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
@@ -17,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
@@ -61,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
@@ -105,13 +100,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon type="symbols" name="speech_to_text" />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
</TabList>
</div>
<div className={tabPanelContainerStyle}>
@@ -123,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,
])
}

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