Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8436d9ae2 | |||
| 39fb273201 | |||
| d101459115 | |||
| 88696a23fd | |||
| 13d26a76b3 | |||
| b675517a60 | |||
| a5254ffd59 | |||
| ff82bca9ec | |||
| 99a18b6e90 | |||
| 250e599465 | |||
| 144a4e1b85 | |||
| 78ab3cdbdf | |||
| a815d6c00d | |||
| dfbc3a9d17 | |||
| 086db3d089 | |||
| 014ef3d804 | |||
| de3e1a56a8 | |||
| 459749b992 | |||
| e1450329f2 | |||
| c7e3194331 | |||
| 902b005f32 | |||
| 51d22783b2 | |||
| 76f80a0f2f | |||
| 82eb930200 | |||
| eeeb950e08 | |||
| cb77688572 | |||
| f9524b2f0a | |||
| a50aabeaf8 | |||
| 594bd5a692 | |||
| 69d92e6f30 | |||
| c47e830b40 | |||
| d7f1b7b94c | |||
| 8072d2c950 | |||
| 726f9097f9 | |||
| bbc7fa8012 | |||
| 41db3e766b | |||
| 1ab3ce6d47 | |||
| 3cd5c77f42 | |||
| 3ddb075c6b | |||
| 9ed2500565 | |||
| 1001783d3c | |||
| 97b5e8780c | |||
| 7c7074aa99 | |||
| 35b3bcad63 | |||
| 137a2c7f6f | |||
| d681e25bcc | |||
| 1f1a6371b4 | |||
| bbfbb23be5 | |||
| 6e20bc1f43 | |||
| fed05f2396 | |||
| 865acf2838 | |||
| 6ae68013af | |||
| 394a1be322 | |||
| a71a1fd968 | |||
| 40af264562 | |||
| 8b2d06976e | |||
| 58313666ed | |||
| f3c8aec189 | |||
| 0a0c7ba618 | |||
| d7ad5aed05 | |||
| 4acc9cf40d | |||
| 13d0d3d801 | |||
| 47cd3eff74 | |||
| 5769203705 | |||
| cadc186c62 | |||
| 5be7595533 | |||
| 0fe8d9b681 | |||
| 83654cf7c0 | |||
| f6cdb1125b | |||
| 2863aa832d | |||
| 48af2e3a5f | |||
| 8a0dfd1478 | |||
| 37a2f3985a | |||
| 39271544d7 | |||
| f7b45622bc | |||
| f3e2bbf701 | |||
| 6e1ad7fca5 | |||
| d9dbededee | |||
| 70403ad0d8 | |||
| 9d69fe4f4f | |||
| 08f281e778 | |||
| da3dfedcbc | |||
| 16badde82d | |||
| 57a7523cc4 | |||
| 398ef1ae8a | |||
| f7d463f380 | |||
| 5e1705d259 | |||
| 236245740f | |||
| 9ebf2f277b | |||
| 049a9079c4 | |||
| 19f8c96e9d | |||
| 857b4bd1f1 | |||
| 309c532811 | |||
| 4e5032a7a4 | |||
| 4cb6320b83 | |||
| 587a5bc574 | |||
| 0d8c76cd03 | |||
| b19ac7f82b | |||
| d3e6af6f82 | |||
| 2fbb476b02 | |||
| 1b2139a9ff | |||
| 54e47e33a9 | |||
| 20b99cf2ad | |||
| db75b0eae9 | |||
| 5163f849e4 | |||
| 4345711771 | |||
| 7c690c369e | |||
| ef09629566 | |||
| cff1dbf39e | |||
| b466515306 | |||
| c678e9420e | |||
| 3af115dafb | |||
| 0daa6d0432 | |||
| 493d7b96f1 | |||
| c2c478c367 | |||
| b5895ccba0 | |||
| aff87d4953 | |||
| c81ef38005 | |||
| 4256eb403d | |||
| 43f3e4691b | |||
| 10aac93c36 | |||
| 4e6bc157b0 | |||
| fe83c5fa07 | |||
| 827014c952 | |||
| 9523f52546 | |||
| 8348a55f7e | |||
| a4b76433ab | |||
| ae863418cd | |||
| dcdae26610 | |||
| 90c0442d35 | |||
| 9093371d25 | |||
| 1d45d3aa7c | |||
| fcb89c520e | |||
| 309ce0989d | |||
| a6c154374f | |||
| b0e27b38e2 | |||
| 9bdc68f9c9 | |||
| 4545e9fa1e | |||
| 3f1edbf134 | |||
| 4f2764eef4 | |||
| b11cc6e9da | |||
| 0a7eb97c90 | |||
| db188075af |
@@ -37,12 +37,12 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# with:
|
||||
# docker-build-args: '--target backend-production -f Dockerfile'
|
||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -147,6 +147,14 @@ 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
|
||||
@@ -178,6 +186,14 @@ 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
|
||||
|
||||
+79
-2
@@ -1,4 +1,3 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
@@ -9,4 +8,82 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
-
|
||||
## [1.4.0] - 2026-01-25
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add configurable redirect for unauthenticated users #904
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) add accessible back button in side panel #881
|
||||
- ♿️(frontend) improve participants toggle a11y label #880
|
||||
- ♿️(frontend) make carousel image decorative #871
|
||||
- ♿️(frontend) reactions are now vocalized and configurable #849
|
||||
- ♿️(frontend) improve background effect announcements #879
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒(backend) prevent automatic upgrade setuptools
|
||||
- ♿(frontend) improve contrast for selected options #863
|
||||
- ♿️(frontend) announce copy state in invite dialog #877
|
||||
- 📝(frontend) align close dialog label in rooms locale #878
|
||||
- 🩹(backend) use case-insensitive email matching in the external api #887
|
||||
- 🐛(frontend) ensure transcript segments are sorted by their timestamp #899
|
||||
- 🐛(frontend) scope scrollbar gutter override to video rooms #882
|
||||
|
||||
## [1.3.0] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(summary) add dutch and german languages
|
||||
- 🔧(agents) make Silero VAD optional
|
||||
- 🚸(frontend) explain to a user they were ejected
|
||||
|
||||
### Changed
|
||||
|
||||
- 📈(frontend) track new recording's modes
|
||||
- ♿️(frontend) improve accessibility of the background and effects menu
|
||||
- ♿️(frontend) improve SR and focus for transcript and recording #810
|
||||
- 💄(frontend) adjust spacing in the recording side panels
|
||||
- 🚸(frontend) remove the default comma delimiter in humanized durations
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
|
||||
- 🩹(frontend) icon font loading to avoid text/icon flickering
|
||||
|
||||
## [1.2.0] - 2026-01-05
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(agent) support Kyutai client for subtitle
|
||||
- ✨(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
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apk update && \
|
||||
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/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
@@ -235,7 +235,7 @@ services:
|
||||
- livekit-egress
|
||||
|
||||
livekit-egress:
|
||||
image: livekit/egress
|
||||
image: livekit/egress:v1.11.0
|
||||
environment:
|
||||
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
||||
volumes:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Installation with docker compose
|
||||
|
||||
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
|
||||
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
|
||||
|
||||
## Step 2: Configuration
|
||||
|
||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
|
||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
|
||||
|
||||
In this example, we assume the following services:
|
||||
|
||||
|
||||
@@ -277,7 +277,6 @@ 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 |
|
||||
|
||||
+3
-4
@@ -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,7 +21,6 @@ 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.
|
||||
|
||||
@@ -40,7 +39,7 @@ tags:
|
||||
description: Room management operations
|
||||
|
||||
paths:
|
||||
/application/token:
|
||||
/application/token/:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
@@ -283,7 +282,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:
|
||||
|
||||
@@ -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
|
||||
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
APP_NAME="meet-app-summary-dev"
|
||||
APP_API_TOKEN="password"
|
||||
|
||||
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||
AWS_S3_SECURE_ACCESS=false
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from lasuite.plugins import kyutai
|
||||
from livekit import api, rtc
|
||||
from livekit.agents import (
|
||||
Agent,
|
||||
@@ -13,14 +14,15 @@ 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()
|
||||
@@ -28,60 +30,26 @@ load_dotenv()
|
||||
logger = logging.getLogger("transcriber")
|
||||
|
||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||
|
||||
# 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",
|
||||
}
|
||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||
|
||||
|
||||
def _build_deepgram_stt_kwargs():
|
||||
"""Build Deepgram STT kwargs from DEEPGRAM_STT_* environment variables.
|
||||
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}")
|
||||
|
||||
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
|
||||
return _stt_instance
|
||||
|
||||
|
||||
class Transcriber(Agent):
|
||||
@@ -89,12 +57,11 @@ class Transcriber(Agent):
|
||||
|
||||
def __init__(self, *, participant_identity: str):
|
||||
"""Init transcription agent."""
|
||||
# Build STT configuration from environment variables
|
||||
stt_kwargs = _build_deepgram_stt_kwargs()
|
||||
stt = create_stt_provider()
|
||||
|
||||
super().__init__(
|
||||
instructions="not-needed",
|
||||
stt=deepgram.STT(**stt_kwargs),
|
||||
stt=stt,
|
||||
)
|
||||
self.participant_identity = participant_identity
|
||||
|
||||
@@ -156,19 +123,14 @@ class MultiUserTranscriber:
|
||||
if participant.identity in self._sessions:
|
||||
return self._sessions[participant.identity]
|
||||
|
||||
session = AgentSession(
|
||||
vad=self.ctx.proc.userdata["vad"],
|
||||
)
|
||||
vad = self.ctx.proc.userdata.get("vad", None)
|
||||
session = AgentSession(vad=vad)
|
||||
room_io = RoomIO(
|
||||
agent_session=session,
|
||||
room=self.ctx.room,
|
||||
participant=participant,
|
||||
input_options=RoomInputOptions(
|
||||
text_enabled=False,
|
||||
),
|
||||
output_options=RoomOutputOptions(
|
||||
transcription_enabled=True,
|
||||
audio_enabled=False,
|
||||
options=lk_room_io.RoomOptions(
|
||||
text_input=False, audio_output=False, text_output=True
|
||||
),
|
||||
)
|
||||
await room_io.start()
|
||||
@@ -231,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
if ENABLE_SILERO_VAD:
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.0.0"
|
||||
version = "1.4.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.2.18",
|
||||
"livekit-plugins-deepgram==1.2.18",
|
||||
"livekit-plugins-silero==1.2.18",
|
||||
"livekit-agents==1.3.10",
|
||||
"livekit-plugins-deepgram==1.3.10",
|
||||
"livekit-plugins-silero==1.3.10",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"python-dotenv==1.2.1"
|
||||
]
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
"status",
|
||||
"mode",
|
||||
"options",
|
||||
"key",
|
||||
"is_expired",
|
||||
"expired_at",
|
||||
@@ -212,6 +213,11 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
"screen_recording or transcript.",
|
||||
},
|
||||
)
|
||||
options = serializers.JSONField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
|
||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
|
||||
@@ -308,10 +308,13 @@ 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)
|
||||
recording = models.Recording.objects.create(
|
||||
room=room, mode=mode, options=options
|
||||
)
|
||||
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
||||
|
||||
@@ -5,7 +5,7 @@ from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
import jwt
|
||||
@@ -93,12 +93,39 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(email=email)
|
||||
user = models.User.objects.get(email__iexact=email)
|
||||
except models.User.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound(
|
||||
{
|
||||
"error": "User not found.",
|
||||
}
|
||||
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
|
||||
except models.User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
@@ -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='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')),
|
||||
('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')),
|
||||
('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')),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -146,7 +146,8 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
"Optional for pending users; required upon account activation. "
|
||||
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
@@ -576,6 +577,12 @@ 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,6 +16,23 @@ 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."""
|
||||
|
||||
@@ -26,7 +43,12 @@ class NotificationService:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
return self._notify_user_by_email(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
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
@@ -64,7 +86,7 @@ class NotificationService:
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_name": recording.room.name,
|
||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
|
||||
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
}
|
||||
|
||||
has_failures = False
|
||||
@@ -137,12 +159,14 @@ 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,7 +1,11 @@
|
||||
"""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__)
|
||||
@@ -14,6 +18,27 @@ 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,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
|
||||
from core import utils
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
|
||||
from .exceptions import (
|
||||
@@ -60,6 +61,15 @@ 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,
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core import models, utils
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
@@ -118,8 +118,10 @@ class LiveKitEventsService:
|
||||
except Exception as e:
|
||||
raise InvalidPayloadError("Invalid webhook payload") from e
|
||||
|
||||
if self._filter_regex and not self._filter_regex.search(data.room.name):
|
||||
logger.info("Filtered webhook event for room '%s'", data.room.name)
|
||||
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)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -138,6 +140,20 @@ 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."""
|
||||
|
||||
@@ -150,6 +166,14 @@ 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,6 +60,26 @@ 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,6 +82,7 @@ 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,6 +95,7 @@ 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,
|
||||
}
|
||||
@@ -130,6 +131,7 @@ 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,
|
||||
}
|
||||
@@ -169,6 +171,7 @@ 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
|
||||
}
|
||||
@@ -209,6 +212,7 @@ 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,6 +2,7 @@
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -33,14 +34,18 @@ def mediator(mock_worker_service):
|
||||
return WorkerServiceMediator(mock_worker_service)
|
||||
|
||||
|
||||
def test_start_recording_success(mediator, mock_worker_service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_start_recording_success(
|
||||
mock_update_room_metadata, 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)
|
||||
|
||||
@@ -55,12 +60,18 @@ def test_start_recording_success(mediator, mock_worker_service):
|
||||
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(
|
||||
mediator, mock_worker_service, error_class
|
||||
mock_update_room_metadata, mediator, mock_worker_service, error_class
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -78,6 +89,8 @@ 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",
|
||||
@@ -90,8 +103,9 @@ def test_mediator_start_recording_worker_errors(
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_mediator_start_recording_from_forbidden_status(
|
||||
mediator, mock_worker_service, status
|
||||
mock_update_room_metadata, mediator, mock_worker_service, status
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -105,6 +119,8 @@ def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == status
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
|
||||
|
||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||
"""Test successful recording stop"""
|
||||
|
||||
@@ -21,7 +21,7 @@ from core.services.livekit_events import (
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import NotificationError
|
||||
from core.utils import MetadataUpdateException, NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -70,7 +70,10 @@ def test_initialization(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_success(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
@@ -83,13 +86,98 @@ def test_handle_egress_ended_success(mock_notify, mode, notification_type, servi
|
||||
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")
|
||||
def test_handle_egress_ended_notification_fails(mock_notify, service):
|
||||
@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
|
||||
):
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
@@ -108,9 +196,16 @@ def test_handle_egress_ended_notification_fails(mock_notify, service):
|
||||
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")
|
||||
def test_handle_egress_ended_recording_not_found(mock_notify, service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_recording_not_found(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
@@ -124,13 +219,17 @@ def test_handle_egress_ended_recording_not_found(mock_notify, service):
|
||||
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")
|
||||
def test_handle_egress_ended_recording_not_active(mock_notify, service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_recording_not_active(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore non-active recordings."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
||||
@@ -141,13 +240,19 @@ def test_handle_egress_ended_recording_not_active(mock_notify, service):
|
||||
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")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
||||
@@ -158,6 +263,9 @@ def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from core.factories import (
|
||||
ApplicationFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.models import ApplicationScope
|
||||
from core.models import ApplicationScope, User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory(email="user@example.com")
|
||||
UserFactory(email="User.Family@example.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
"scope": "user.family@example.com",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -232,6 +232,7 @@ 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],
|
||||
@@ -273,3 +274,119 @@ 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",
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ from livekit.api import ( # pylint: disable=E0611
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
VideoGrants,
|
||||
)
|
||||
|
||||
@@ -244,6 +245,57 @@ 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.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -177,61 +177,61 @@ msgstr "Sub"
|
||||
|
||||
#: core/models.py:149
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
|
||||
"erlaubt."
|
||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||
|
||||
#: core/models.py:157
|
||||
#: core/models.py:158
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:162
|
||||
#: core/models.py:163
|
||||
msgid "admin email address"
|
||||
msgstr "Administrator-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:164
|
||||
#: core/models.py:165
|
||||
msgid "full name"
|
||||
msgstr "Vollständiger Name"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:167
|
||||
msgid "short name"
|
||||
msgstr "Kurzname"
|
||||
|
||||
#: core/models.py:172
|
||||
#: core/models.py:173
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:174
|
||||
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:179
|
||||
#: core/models.py:180
|
||||
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:182
|
||||
#: core/models.py:183
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:185
|
||||
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:187
|
||||
#: core/models.py:188
|
||||
msgid "staff status"
|
||||
msgstr "Mitarbeiterstatus"
|
||||
|
||||
#: core/models.py:189
|
||||
#: core/models.py:190
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||
|
||||
#: core/models.py:192
|
||||
#: core/models.py:193
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:196
|
||||
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:208
|
||||
#: core/models.py:209
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:209
|
||||
#: core/models.py:210
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:268
|
||||
#: core/models.py:269
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:269
|
||||
#: core/models.py:270
|
||||
msgid "Resources"
|
||||
msgstr "Ressourcen"
|
||||
|
||||
#: core/models.py:323
|
||||
#: core/models.py:324
|
||||
msgid "Resource access"
|
||||
msgstr "Ressourcenzugriff"
|
||||
|
||||
#: core/models.py:324
|
||||
#: core/models.py:325
|
||||
msgid "Resource accesses"
|
||||
msgstr "Ressourcenzugriffe"
|
||||
|
||||
#: core/models.py:330
|
||||
#: core/models.py:331
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||
"bereits."
|
||||
|
||||
#: core/models.py:386
|
||||
#: core/models.py:387
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-Raumkonfiguration"
|
||||
|
||||
#: core/models.py:387
|
||||
#: core/models.py:388
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:395
|
||||
msgid "Room PIN code"
|
||||
msgstr "PIN-Code für den Raum"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:396
|
||||
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:401 core/models.py:555
|
||||
#: core/models.py:402 core/models.py:556
|
||||
msgid "Room"
|
||||
msgstr "Raum"
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:403
|
||||
msgid "Rooms"
|
||||
msgstr "Räume"
|
||||
|
||||
#: core/models.py:566
|
||||
#: core/models.py:567
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker-ID"
|
||||
|
||||
#: core/models.py:568
|
||||
#: core/models.py:569
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -307,99 +307,104 @@ msgstr ""
|
||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||
"ermöglicht."
|
||||
|
||||
#: core/models.py:576
|
||||
#: core/models.py:577
|
||||
msgid "Recording mode"
|
||||
msgstr "Aufzeichnungsmodus"
|
||||
|
||||
#: core/models.py:577
|
||||
#: core/models.py:578
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||
|
||||
#: core/models.py:583
|
||||
#: core/models.py:583 core/models.py:584
|
||||
msgid "Recording options"
|
||||
msgstr "Aufnahmeoptionen"
|
||||
|
||||
#: core/models.py:590
|
||||
msgid "Recording"
|
||||
msgstr "Aufzeichnung"
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:591
|
||||
msgid "Recordings"
|
||||
msgstr "Aufzeichnungen"
|
||||
|
||||
#: core/models.py:692
|
||||
#: core/models.py:699
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:693
|
||||
#: core/models.py:700
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:699
|
||||
#: core/models.py:706
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:705
|
||||
#: core/models.py:712
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:711
|
||||
#: core/models.py:718
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||
|
||||
#: core/models.py:728
|
||||
#: core/models.py:735
|
||||
msgid "Create rooms"
|
||||
msgstr "Räume erstellen"
|
||||
|
||||
#: core/models.py:729
|
||||
#: core/models.py:736
|
||||
msgid "List rooms"
|
||||
msgstr "Räume auflisten"
|
||||
|
||||
#: core/models.py:730
|
||||
#: core/models.py:737
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Raumdetails abrufen"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:738
|
||||
msgid "Update rooms"
|
||||
msgstr "Räume aktualisieren"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:739
|
||||
msgid "Delete rooms"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:752
|
||||
msgid "Application name"
|
||||
msgstr "Anwendungsname"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:753
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschreibender Name für diese Anwendung."
|
||||
|
||||
#: core/models.py:756
|
||||
#: core/models.py:763
|
||||
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:767
|
||||
#: core/models.py:774
|
||||
msgid "Application"
|
||||
msgstr "Anwendung"
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:775
|
||||
msgid "Applications"
|
||||
msgstr "Anwendungen"
|
||||
|
||||
#: core/models.py:791
|
||||
#: core/models.py:798
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Geben Sie eine gültige Domain ein"
|
||||
|
||||
#: core/models.py:794
|
||||
#: core/models.py:801
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:802
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||
|
||||
#: core/models.py:807
|
||||
#: core/models.py:814
|
||||
msgid "Application domain"
|
||||
msgstr "Anwendungsdomain"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:815
|
||||
msgid "Application domains"
|
||||
msgstr "Anwendungsdomains"
|
||||
|
||||
@@ -531,18 +536,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:167
|
||||
#: meet/settings.py:169
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:168
|
||||
#: meet/settings.py:170
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:169
|
||||
#: meet/settings.py:171
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:170
|
||||
#: meet/settings.py:172
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -175,61 +175,61 @@ msgstr "sub"
|
||||
|
||||
#: core/models.py:149
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
|
||||
#: core/models.py:157
|
||||
#: core/models.py:158
|
||||
msgid "identity email address"
|
||||
msgstr "identity email address"
|
||||
|
||||
#: core/models.py:162
|
||||
#: core/models.py:163
|
||||
msgid "admin email address"
|
||||
msgstr "admin email address"
|
||||
|
||||
#: core/models.py:164
|
||||
#: core/models.py:165
|
||||
msgid "full name"
|
||||
msgstr "full name"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:167
|
||||
msgid "short name"
|
||||
msgstr "short name"
|
||||
|
||||
#: core/models.py:172
|
||||
#: core/models.py:173
|
||||
msgid "language"
|
||||
msgstr "language"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:174
|
||||
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:179
|
||||
#: core/models.py:180
|
||||
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:182
|
||||
#: core/models.py:183
|
||||
msgid "device"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:185
|
||||
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:187
|
||||
#: core/models.py:188
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:189
|
||||
#: core/models.py:190
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Whether the user can log into this admin site."
|
||||
|
||||
#: core/models.py:192
|
||||
#: core/models.py:193
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:196
|
||||
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:208
|
||||
#: core/models.py:209
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:209
|
||||
#: core/models.py:210
|
||||
msgid "users"
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:268
|
||||
#: core/models.py:269
|
||||
msgid "Resource"
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:269
|
||||
#: core/models.py:270
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:323
|
||||
#: core/models.py:324
|
||||
msgid "Resource access"
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:324
|
||||
#: core/models.py:325
|
||||
msgid "Resource accesses"
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:330
|
||||
#: core/models.py:331
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Resource access with this User and Resource already exists."
|
||||
|
||||
#: core/models.py:386
|
||||
#: core/models.py:387
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:387
|
||||
#: core/models.py:388
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Values for Visio parameters to configure the room."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:395
|
||||
msgid "Room PIN code"
|
||||
msgstr "Room PIN code"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:396
|
||||
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:401 core/models.py:555
|
||||
#: core/models.py:402 core/models.py:556
|
||||
msgid "Room"
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:403
|
||||
msgid "Rooms"
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:566
|
||||
#: core/models.py:567
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:568
|
||||
#: core/models.py:569
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -301,103 +301,107 @@ msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:576
|
||||
#: core/models.py:577
|
||||
msgid "Recording mode"
|
||||
msgstr "Recording mode"
|
||||
|
||||
#: core/models.py:577
|
||||
#: core/models.py:578
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:583
|
||||
#: core/models.py:583 core/models.py:584
|
||||
msgid "Recording options"
|
||||
msgstr "Recording options"
|
||||
|
||||
#: core/models.py:590
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:591
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:692
|
||||
#: core/models.py:699
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:693
|
||||
#: core/models.py:700
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:699
|
||||
#: core/models.py:706
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:705
|
||||
#: core/models.py:712
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:711
|
||||
#: core/models.py:718
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/models.py:728
|
||||
#: core/models.py:735
|
||||
#, fuzzy
|
||||
#| msgid "created on"
|
||||
msgid "Create rooms"
|
||||
msgstr "Create rooms"
|
||||
|
||||
#: core/models.py:729
|
||||
#: core/models.py:736
|
||||
msgid "List rooms"
|
||||
msgstr "List rooms"
|
||||
|
||||
#: core/models.py:730
|
||||
#: core/models.py:737
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Retrieve room details"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:738
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Update rooms"
|
||||
msgstr "Update rooms"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:739
|
||||
msgid "Delete rooms"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:752
|
||||
msgid "Application name"
|
||||
msgstr "Application name"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:753
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Descriptive name for this application."
|
||||
|
||||
#: core/models.py:756
|
||||
#: core/models.py:763
|
||||
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:767
|
||||
#: core/models.py:774
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:775
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:791
|
||||
#: core/models.py:798
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Enter a valid domain"
|
||||
|
||||
#: core/models.py:794
|
||||
#: core/models.py:801
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:802
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Email domain this application can act on behalf of."
|
||||
|
||||
#: core/models.py:807
|
||||
#: core/models.py:814
|
||||
msgid "Application domain"
|
||||
msgstr "Application domain"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:815
|
||||
msgid "Application domains"
|
||||
msgstr "Application domains"
|
||||
|
||||
@@ -529,18 +533,18 @@ msgstr ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:167
|
||||
#: meet/settings.py:169
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:168
|
||||
#: meet/settings.py:170
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:169
|
||||
#: meet/settings.py:171
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:170
|
||||
#: meet/settings.py:172
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -179,61 +179,61 @@ msgstr "sub"
|
||||
|
||||
#: core/models.py:149
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
|
||||
"+/-/_ uniquement."
|
||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||
|
||||
#: core/models.py:157
|
||||
#: core/models.py:158
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:162
|
||||
#: core/models.py:163
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
|
||||
#: core/models.py:164
|
||||
#: core/models.py:165
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:167
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:172
|
||||
#: core/models.py:173
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:174
|
||||
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:179
|
||||
#: core/models.py:180
|
||||
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:182
|
||||
#: core/models.py:183
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:185
|
||||
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:187
|
||||
#: core/models.py:188
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
|
||||
#: core/models.py:189
|
||||
#: core/models.py:190
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: core/models.py:192
|
||||
#: core/models.py:193
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:196
|
||||
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:208
|
||||
#: core/models.py:209
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:209
|
||||
#: core/models.py:210
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:268
|
||||
#: core/models.py:269
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:269
|
||||
#: core/models.py:270
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:323
|
||||
#: core/models.py:324
|
||||
msgid "Resource access"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:324
|
||||
#: core/models.py:325
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:330
|
||||
#: core/models.py:331
|
||||
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:386
|
||||
#: core/models.py:387
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:387
|
||||
#: core/models.py:388
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:395
|
||||
msgid "Room PIN code"
|
||||
msgstr "Code PIN de la salle"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:396
|
||||
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:401 core/models.py:555
|
||||
#: core/models.py:402 core/models.py:556
|
||||
msgid "Room"
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:403
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:566
|
||||
#: core/models.py:567
|
||||
msgid "Worker ID"
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:568
|
||||
#: core/models.py:569
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -307,99 +307,105 @@ 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:576
|
||||
#: core/models.py:577
|
||||
msgid "Recording mode"
|
||||
msgstr "Mode d'enregistrement"
|
||||
|
||||
#: core/models.py:577
|
||||
#: core/models.py:578
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:583
|
||||
#: core/models.py:583 core/models.py:584
|
||||
msgid "Recording options"
|
||||
msgstr "Options d'enregistrement"
|
||||
|
||||
#: core/models.py:590
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:591
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:692
|
||||
#: core/models.py:699
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:693
|
||||
#: core/models.py:700
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:699
|
||||
#: core/models.py:706
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:705
|
||||
#: core/models.py:712
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:711
|
||||
#: core/models.py:718
|
||||
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:728
|
||||
#: core/models.py:735
|
||||
msgid "Create rooms"
|
||||
msgstr "Créer des salles"
|
||||
|
||||
#: core/models.py:729
|
||||
#: core/models.py:736
|
||||
msgid "List rooms"
|
||||
msgstr "Lister les salles"
|
||||
|
||||
#: core/models.py:730
|
||||
#: core/models.py:737
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Afficher les détails d’une salle"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:738
|
||||
msgid "Update rooms"
|
||||
msgstr "Mettre à jour les salles"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:739
|
||||
msgid "Delete rooms"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:752
|
||||
msgid "Application name"
|
||||
msgstr "Nom de l’application"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:753
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nom descriptif de cette application."
|
||||
|
||||
#: core/models.py:756
|
||||
#: core/models.py:763
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un nouveau secret."
|
||||
msgstr ""
|
||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||
"nouveau secret."
|
||||
|
||||
#: core/models.py:767
|
||||
#: core/models.py:774
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:775
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:791
|
||||
#: core/models.py:798
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Saisissez un domaine valide"
|
||||
|
||||
#: core/models.py:794
|
||||
#: core/models.py:801
|
||||
msgid "Domain"
|
||||
msgstr "Domaine"
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:802
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||
|
||||
#: core/models.py:807
|
||||
#: core/models.py:814
|
||||
msgid "Application domain"
|
||||
msgstr "Domaine d’application"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:815
|
||||
msgid "Application domains"
|
||||
msgstr "Domaines d’application"
|
||||
|
||||
@@ -531,18 +537,18 @@ msgstr ""
|
||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||
"équipe d'assistance à %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:167
|
||||
#: meet/settings.py:169
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
||||
#: meet/settings.py:168
|
||||
#: meet/settings.py:170
|
||||
msgid "French"
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:169
|
||||
#: meet/settings.py:171
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
#: meet/settings.py:170
|
||||
#: meet/settings.py:172
|
||||
msgid "German"
|
||||
msgstr "Allemand"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-13 16:21+0000\n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -176,60 +176,61 @@ msgstr "sub"
|
||||
|
||||
#: core/models.py:149
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
|
||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||
|
||||
#: core/models.py:157
|
||||
#: core/models.py:158
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit e-mailadres"
|
||||
|
||||
#: core/models.py:162
|
||||
#: core/models.py:163
|
||||
msgid "admin email address"
|
||||
msgstr "beheerder e-mailadres"
|
||||
|
||||
#: core/models.py:164
|
||||
#: core/models.py:165
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:167
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:172
|
||||
#: core/models.py:173
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:174
|
||||
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:179
|
||||
#: core/models.py:180
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||
|
||||
#: core/models.py:182
|
||||
#: core/models.py:183
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:184
|
||||
#: core/models.py:185
|
||||
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:187
|
||||
#: core/models.py:188
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:189
|
||||
#: core/models.py:190
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: core/models.py:192
|
||||
#: core/models.py:193
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: core/models.py:195
|
||||
#: core/models.py:196
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -237,64 +238,64 @@ msgstr ""
|
||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||
"plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:208
|
||||
#: core/models.py:209
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: core/models.py:209
|
||||
#: core/models.py:210
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/models.py:268
|
||||
#: core/models.py:269
|
||||
msgid "Resource"
|
||||
msgstr "Bron"
|
||||
|
||||
#: core/models.py:269
|
||||
#: core/models.py:270
|
||||
msgid "Resources"
|
||||
msgstr "Bronnen"
|
||||
|
||||
#: core/models.py:323
|
||||
#: core/models.py:324
|
||||
msgid "Resource access"
|
||||
msgstr "Brontoegang"
|
||||
|
||||
#: core/models.py:324
|
||||
#: core/models.py:325
|
||||
msgid "Resource accesses"
|
||||
msgstr "Brontoegangsrechten"
|
||||
|
||||
#: core/models.py:330
|
||||
#: core/models.py:331
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||
|
||||
#: core/models.py:386
|
||||
#: core/models.py:387
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-ruimteconfiguratie"
|
||||
|
||||
#: core/models.py:387
|
||||
#: core/models.py:388
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:395
|
||||
msgid "Room PIN code"
|
||||
msgstr "Pincode van de kamer"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:396
|
||||
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:401 core/models.py:555
|
||||
#: core/models.py:402 core/models.py:556
|
||||
msgid "Room"
|
||||
msgstr "Ruimte"
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:403
|
||||
msgid "Rooms"
|
||||
msgstr "Ruimtes"
|
||||
|
||||
#: core/models.py:566
|
||||
#: core/models.py:567
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:568
|
||||
#: core/models.py:569
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -302,99 +303,104 @@ 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:576
|
||||
#: core/models.py:577
|
||||
msgid "Recording mode"
|
||||
msgstr "Opnamemodus"
|
||||
|
||||
#: core/models.py:577
|
||||
#: core/models.py:578
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:583
|
||||
#: core/models.py:583 core/models.py:584
|
||||
msgid "Recording options"
|
||||
msgstr "Opnameopties"
|
||||
|
||||
#: core/models.py:590
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:591
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:692
|
||||
#: core/models.py:699
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:693
|
||||
#: core/models.py:700
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:699
|
||||
#: core/models.py:706
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:705
|
||||
#: core/models.py:712
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:711
|
||||
#: core/models.py:718
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||
|
||||
#: core/models.py:728
|
||||
#: core/models.py:735
|
||||
msgid "Create rooms"
|
||||
msgstr "Ruimtes aanmaken"
|
||||
|
||||
#: core/models.py:729
|
||||
#: core/models.py:736
|
||||
msgid "List rooms"
|
||||
msgstr "Ruimtes weergeven"
|
||||
|
||||
#: core/models.py:730
|
||||
#: core/models.py:737
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Details van een ruimte ophalen"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:738
|
||||
msgid "Update rooms"
|
||||
msgstr "Ruimtes bijwerken"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:739
|
||||
msgid "Delete rooms"
|
||||
msgstr "Ruimtes verwijderen"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:752
|
||||
msgid "Application name"
|
||||
msgstr "Naam van de applicatie"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:753
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschrijvende naam voor deze applicatie."
|
||||
|
||||
#: core/models.py:756
|
||||
#: core/models.py:763
|
||||
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:767
|
||||
#: core/models.py:774
|
||||
msgid "Application"
|
||||
msgstr "Applicatie"
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:775
|
||||
msgid "Applications"
|
||||
msgstr "Applicaties"
|
||||
|
||||
#: core/models.py:791
|
||||
#: core/models.py:798
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Voer een geldig domein in"
|
||||
|
||||
#: core/models.py:794
|
||||
#: core/models.py:801
|
||||
msgid "Domain"
|
||||
msgstr "Domein"
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:802
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||
|
||||
#: core/models.py:807
|
||||
#: core/models.py:814
|
||||
msgid "Application domain"
|
||||
msgstr "Applicatiedomein"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:815
|
||||
msgid "Application domains"
|
||||
msgstr "Applicatiedomeinen"
|
||||
|
||||
@@ -526,18 +532,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:167
|
||||
#: meet/settings.py:169
|
||||
msgid "English"
|
||||
msgstr "Engels"
|
||||
|
||||
#: meet/settings.py:168
|
||||
#: meet/settings.py:170
|
||||
msgid "French"
|
||||
msgstr "Frans"
|
||||
|
||||
#: meet/settings.py:169
|
||||
#: meet/settings.py:171
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
|
||||
#: meet/settings.py:170
|
||||
#: meet/settings.py:172
|
||||
msgid "German"
|
||||
msgstr "Duits"
|
||||
|
||||
@@ -336,18 +336,21 @@ class Base(Configuration):
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
"external_home_url": values.Value(
|
||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||
),
|
||||
"use_french_gov_footer": values.BooleanValue(
|
||||
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
|
||||
),
|
||||
"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,6 +408,10 @@ 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
|
||||
@@ -630,6 +637,9 @@ 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(
|
||||
@@ -770,6 +780,14 @@ 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
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.0.0"
|
||||
version = "1.4.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -56,7 +56,7 @@ dependencies = [
|
||||
"whitenoise==6.11.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==1.0.7",
|
||||
"aiohttp==3.13.2",
|
||||
"aiohttp==3.13.3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -6,6 +6,22 @@
|
||||
<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>
|
||||
|
||||
Generated
+28
-25
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.0",
|
||||
"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",
|
||||
@@ -104,7 +106,6 @@
|
||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -1019,6 +1020,24 @@
|
||||
"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",
|
||||
@@ -3870,7 +3889,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz",
|
||||
"integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.81.5"
|
||||
},
|
||||
@@ -3962,7 +3980,8 @@
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-transform": {
|
||||
"version": "0.1.11",
|
||||
@@ -3978,7 +3997,8 @@
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.15.tgz",
|
||||
"integrity": "sha512-omOlCPvTWyPm4ZE5bZUhlSvnHM2ZWM2U+1cPiYFL/e8aV5O9MouELp+L4dMKNTON0nTeHqEg+KWDfFQMY5Wkaw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
@@ -4005,7 +4025,6 @@
|
||||
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -4023,7 +4042,6 @@
|
||||
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -4090,7 +4108,6 @@
|
||||
"integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.1",
|
||||
"@typescript-eslint/types": "8.35.1",
|
||||
@@ -4382,7 +4399,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
|
||||
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4797,7 +4813,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001646",
|
||||
"electron-to-chromium": "^1.5.4",
|
||||
@@ -5607,7 +5622,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
|
||||
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -6575,7 +6589,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -7460,7 +7473,6 @@
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.7.tgz",
|
||||
"integrity": "sha512-19m8Q1cvRl5PslRawDUgWXeP8vL8584tX8kiZEJaPZo83U/L6VPS/O7pP06phfJaBWeeV8sAOVtEPlQiZEHtpg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.39.3",
|
||||
@@ -8120,7 +8132,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8234,7 +8245,6 @@
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -8388,7 +8398,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -8495,7 +8504,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -8745,6 +8753,7 @@
|
||||
"integrity": "sha512-GBg5pV8LHOTbeVmH2VHLEFR0mc2QpQMzAvcoxEGfPNWgWHc8UvKCyq7pqN1vA+fDZ+yXXbixeO0kB2pzVvFCBw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"postcss": "^8.4.38"
|
||||
}
|
||||
@@ -9248,7 +9257,6 @@
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -9367,8 +9375,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
@@ -9480,7 +9487,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9641,7 +9647,6 @@
|
||||
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
|
||||
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"proxy-compare": "^3.0.1"
|
||||
},
|
||||
@@ -9742,7 +9747,6 @@
|
||||
"integrity": "sha512-cJBdq0/u+8rgstg9t7UkBilf8ipLmeXJO30NxD5HAHOivnj10ocV8YtR/XBvd2wQpN3TmcaxNKaHX3tN7o5F5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
@@ -9877,7 +9881,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,6 +13,8 @@
|
||||
"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",
|
||||
|
||||
@@ -17,9 +17,7 @@ export interface ApiConfig {
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
transcript: {
|
||||
form_beta_users: string
|
||||
}
|
||||
external_home_url?: string
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
custom_css_url?: string
|
||||
@@ -47,6 +45,7 @@ export interface ApiConfig {
|
||||
enable_firefox_proxy_workaround: boolean
|
||||
default_sources: string[]
|
||||
}
|
||||
transcription_destination?: string
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
|
||||
@@ -68,6 +68,7 @@ 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,6 +6,7 @@ export const BlurOnStrong = () => {
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, LinkButton } from '@/primitives'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
const Heading = styled('h2', {
|
||||
base: {
|
||||
@@ -165,14 +164,7 @@ export const IntroSlider = () => {
|
||||
const [slideIndex, setSlideIndex] = useState(0)
|
||||
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
const filteredSlides = useMemo(
|
||||
() => (data?.transcript?.form_beta_users ? SLIDES : SLIDES.slice(0, 2)),
|
||||
[data]
|
||||
)
|
||||
|
||||
const NUMBER_SLIDES = filteredSlides.length
|
||||
const NUMBER_SLIDES = SLIDES.length
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@@ -198,24 +190,12 @@ export const IntroSlider = () => {
|
||||
</ButtonVerticalCenter>
|
||||
</ButtonContainer>
|
||||
<SlideContainer>
|
||||
{filteredSlides.map((slide, index) => (
|
||||
{SLIDES.map((slide, index) => (
|
||||
<Slide visible={index == slideIndex} key={index}>
|
||||
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
|
||||
<Image src={slide.src} alt="" role="presentation" />
|
||||
<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>
|
||||
))}
|
||||
@@ -241,7 +221,7 @@ export const IntroSlider = () => {
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
{filteredSlides.map((_, index) => (
|
||||
{SLIDES.map((_, index) => (
|
||||
<Dot key={index} selected={index == slideIndex} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
@@ -19,6 +19,7 @@ import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePers
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -155,9 +156,34 @@ export const Home = () => {
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
useEffect(() => {
|
||||
const checkSiteAndRedirect = async () => {
|
||||
if (!data?.external_home_url) return
|
||||
if (isLoggedIn === false) {
|
||||
try {
|
||||
await fetch(data.external_home_url, {
|
||||
method: 'HEAD', // Use HEAD to avoid downloading the full page
|
||||
mode: 'no-cors', // Needed for cross-origin requests
|
||||
})
|
||||
window.location.replace(data.external_home_url)
|
||||
} catch (error) {
|
||||
setRedirectFailed(true)
|
||||
console.error('Site is not reachable:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkSiteAndRedirect()
|
||||
}, [isLoggedIn, data])
|
||||
|
||||
if (data?.external_home_url && isLoggedIn == false && !redirectFailed) {
|
||||
return <LoadingScreen header={false} footer={false} delay={0} />
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
|
||||
@@ -111,15 +111,13 @@ export const TermsOfServiceRoute = () => {
|
||||
))}
|
||||
|
||||
{/* Article 7 */}
|
||||
<H lvl={2} margin={false}>
|
||||
{t('articles.article7.title')}
|
||||
</H>
|
||||
<H lvl={2}>{t('articles.article7.title')}</H>
|
||||
<P>{t('articles.article7.content')}</P>
|
||||
|
||||
{/* 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,
|
||||
@@ -132,16 +130,51 @@ 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.section2.content')
|
||||
{t('articles.article7.sections.section5.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.section2.content').split(
|
||||
t('articles.article7.sections.section5.content').split(
|
||||
'https://github.com/suitenumerique/meet'
|
||||
)[1]
|
||||
}
|
||||
|
||||
@@ -104,6 +104,16 @@ 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,4 +13,5 @@ export const NotificationDuration = {
|
||||
LOWER_HAND: ToastDuration.EXTRA_LONG,
|
||||
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||
} as const
|
||||
|
||||
@@ -9,8 +9,10 @@ export enum NotificationType {
|
||||
TranscriptionStarted = 'transcriptionStarted',
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
TranscriptionLimitReached = 'transcriptionLimitReached',
|
||||
TranscriptionRequested = 'transcriptionRequested',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
ScreenRecordingRequested = 'screenRecordingRequested',
|
||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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,6 +12,7 @@ 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>
|
||||
@@ -52,6 +53,12 @@ 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,16 +7,19 @@ 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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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,35 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Dialog, P } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import humanizeDuration from 'humanize-duration'
|
||||
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'
|
||||
|
||||
export const LimitReachedAlertDialog = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation('rooms', {
|
||||
export const LimitReachedAlertDialog = () => {
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'recordingStateToast.limitReachedAlert',
|
||||
})
|
||||
const { data } = useConfig()
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} role="alertdialog" title={t('title')}>
|
||||
<Dialog isOpen={isAlertOpen} role="alertdialog" title={t('title')}>
|
||||
<P>
|
||||
{t('description', {
|
||||
duration_message: data?.recording?.max_duration
|
||||
duration_message: maxDuration
|
||||
? t('durationMessage', {
|
||||
duration: humanizeDuration(data?.recording?.max_duration, {
|
||||
language: i18n.language,
|
||||
}),
|
||||
duration: maxDuration,
|
||||
})
|
||||
: '',
|
||||
})}
|
||||
</P>
|
||||
<HStack gap={1}>
|
||||
<Button variant="text" size="sm" onPress={onClose}>
|
||||
<Button variant="text" size="sm" onPress={() => setIsAlertOpen(false)}>
|
||||
{t('button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
||||
import { RecordingStateToast } from './RecordingStateToast'
|
||||
import { ErrorAlertDialog } from './ErrorAlertDialog'
|
||||
|
||||
export const RecordingProvider = () => {
|
||||
return (
|
||||
<>
|
||||
<RecordingStateToast />
|
||||
<LimitReachedAlertDialog />
|
||||
<ErrorAlertDialog />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,210 +1,167 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useRef, useState, useEffect } 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,
|
||||
useIsRecordingActive,
|
||||
useRecordingStatuses,
|
||||
} 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 { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
||||
import { useRoomMetadata } from '../hooks/useRoomMetadata'
|
||||
import { RecordingStatusIcon } from './RecordingStatusIcon'
|
||||
import { useIsRecording } from '@livekit/components-react'
|
||||
|
||||
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 recordingSnap = useSnapshot(recordingStore)
|
||||
const [srMessage, setSrMessage] = useState('')
|
||||
const lastKeyRef = useRef('')
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
|
||||
const isScreenRecordingActive = useIsRecordingActive(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
const {
|
||||
isStarted: isScreenRecordingStarted,
|
||||
isStarting: isScreenRecordingStarting,
|
||||
isActive: isScreenRecordingActive,
|
||||
} = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
|
||||
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: isTranscriptStarted,
|
||||
isStarting: isTranscriptStarting,
|
||||
isActive: isTranscriptActive,
|
||||
} = useRecordingStatuses(RecordingMode.Transcript)
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (payload: Uint8Array) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
const isStarted = isScreenRecordingStarted || isTranscriptStarted
|
||||
const isStarting = isTranscriptStarting || isScreenRecordingStarting
|
||||
|
||||
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 metadata = useRoomMetadata()
|
||||
const isRecording = useIsRecording()
|
||||
|
||||
const key = useMemo(() => {
|
||||
switch (recordingSnap.status) {
|
||||
case RecordingStatus.TRANSCRIPT_STARTED:
|
||||
return 'transcript.started'
|
||||
case RecordingStatus.TRANSCRIPT_STARTING:
|
||||
return 'transcript.starting'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTED:
|
||||
return 'screenRecording.started'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTING:
|
||||
return 'screenRecording.starting'
|
||||
case RecordingStatus.ANY_STARTED:
|
||||
return 'any.started'
|
||||
default:
|
||||
return
|
||||
if (!metadata?.recording_status || !metadata?.recording_mode) {
|
||||
return undefined
|
||||
}
|
||||
}, [recordingSnap])
|
||||
|
||||
if (!key)
|
||||
return isAdminOrOwner ? (
|
||||
<LimitReachedAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
aria-label="Recording limit exceeded"
|
||||
/>
|
||||
) : null
|
||||
if (!isStarting && !isStarted) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isStarted = key?.includes('started')
|
||||
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 hasScreenRecordingAccessAndActive =
|
||||
isScreenRecordingActive && hasScreenRecordingAccess
|
||||
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
||||
|
||||
return (
|
||||
<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',
|
||||
})}
|
||||
<>
|
||||
{/* 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}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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" />
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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,18 +1,15 @@
|
||||
import { A, Button, Dialog, Div, H, P, Text } from '@/primitives'
|
||||
import { A, Div, H, 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,
|
||||
useIsRecordingTransitioning,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
useHumanizeRecordingMaxDuration,
|
||||
useRecordingStatuses,
|
||||
} from '@/features/recording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
import {
|
||||
NotificationType,
|
||||
@@ -20,59 +17,48 @@ 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 humanizeDuration from 'humanize-duration'
|
||||
import i18n from 'i18next'
|
||||
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'
|
||||
|
||||
export const ScreenRecordingSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
|
||||
const recordingMaxDuration = useHumanizeRecordingMaxDuration()
|
||||
|
||||
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
const keyPrefix = 'screenRecording'
|
||||
const { t } = useTranslation('rooms', { keyPrefix })
|
||||
|
||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
const { selectedLanguageKey, isLanguageSetToAuto } =
|
||||
useTranscriptionLanguage()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
|
||||
useMutateRecording()
|
||||
|
||||
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 statuses = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
const { openTranscript } = useSidePanel()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
const handleRequestScreenRecording = async () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingRequested,
|
||||
})
|
||||
posthog.capture('screen-recording-requested', {})
|
||||
}
|
||||
|
||||
const handleScreenRecording = async () => {
|
||||
if (!roomId) {
|
||||
@@ -80,10 +66,10 @@ export const ScreenRecordingSidePanel = () => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
if (statuses.isStarted || statuses.isStarting) {
|
||||
setIncludeTranscript(false)
|
||||
await stopRecording({ id: roomId })
|
||||
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStopped,
|
||||
})
|
||||
@@ -92,30 +78,44 @@ export const ScreenRecordingSidePanel = () => {
|
||||
room.localParticipant
|
||||
)
|
||||
} else {
|
||||
await startRecordingRoom({
|
||||
const recordingOptions = {
|
||||
...(!isLanguageSetToAuto && {
|
||||
language: selectedLanguageKey,
|
||||
}),
|
||||
...(includeTranscript && { transcribe: true }),
|
||||
}
|
||||
|
||||
await startRecording({
|
||||
id: roomId,
|
||||
mode: RecordingMode.ScreenRecording,
|
||||
options: recordingOptions,
|
||||
})
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStarted,
|
||||
})
|
||||
posthog.capture('screen-recording-started', {})
|
||||
posthog.capture('screen-recording-started', {
|
||||
includeTranscript: includeTranscript,
|
||||
language: selectedLanguageKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
console.error('Failed to handle recording:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading ||
|
||||
isRecordingTransitioning ||
|
||||
statuses.isAnotherModeStarted ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Div
|
||||
@@ -128,130 +128,74 @@ export const ScreenRecordingSidePanel = () => {
|
||||
>
|
||||
<img
|
||||
src="/assets/intro-slider/4.png"
|
||||
alt={''}
|
||||
alt=""
|
||||
className={css({
|
||||
minHeight: '309px',
|
||||
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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{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={() => 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>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
</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',
|
||||
})}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
isSelected={includeTranscript}
|
||||
onChange={setIncludeTranscript}
|
||||
isDisabled={statuses.isActive || isPendingToStart}
|
||||
>
|
||||
<Text variant="sm">{t('details.transcription')}</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</VStack>
|
||||
<ControlsButton
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
handle={handleScreenRecording}
|
||||
statuses={statuses}
|
||||
isPendingToStart={isPendingToStart}
|
||||
isPendingToStop={isPendingToStop}
|
||||
openSidePanel={openTranscript}
|
||||
/>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Button, Dialog, Div, H, LinkButton, P, Text } from '@/primitives'
|
||||
import { A, Button, Div, H, Text } from '@/primitives'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
@@ -6,15 +6,12 @@ import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
useIsRecordingTransitioning,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
useHumanizeRecordingMaxDuration,
|
||||
useRecordingStatuses,
|
||||
} from '../index'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import {
|
||||
NotificationType,
|
||||
@@ -22,23 +19,35 @@ 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 humanizeDuration from 'humanize-duration'
|
||||
import i18n from 'i18next'
|
||||
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'
|
||||
|
||||
export const TranscriptSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
const recordingMaxDuration = useHumanizeRecordingMaxDuration()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
|
||||
const keyPrefix = 'transcript'
|
||||
const { t } = useTranslation('rooms', { keyPrefix })
|
||||
|
||||
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
const [includeScreenRecording, setIncludeScreenRecording] = useState(false)
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
||||
useTranscriptionLanguage()
|
||||
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
@@ -52,40 +61,20 @@ export const TranscriptSidePanel = () => {
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
|
||||
useMutateRecording()
|
||||
|
||||
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 statuses = useRecordingStatuses(RecordingMode.Transcript)
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
const { openScreenRecording } = useSidePanel()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
const handleRequestTranscription = async () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionRequested,
|
||||
})
|
||||
posthog.capture('transcript-requested', {})
|
||||
}
|
||||
|
||||
const handleTranscript = async () => {
|
||||
if (!roomId) {
|
||||
@@ -93,10 +82,10 @@ export const TranscriptSidePanel = () => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
if (statuses.isStarted || statuses.isStarting) {
|
||||
await stopRecording({ id: roomId })
|
||||
setIncludeScreenRecording(false)
|
||||
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionStopped,
|
||||
})
|
||||
@@ -105,27 +94,64 @@ export const TranscriptSidePanel = () => {
|
||||
room.localParticipant
|
||||
)
|
||||
} else {
|
||||
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
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 notifyParticipants({
|
||||
type: NotificationType.TranscriptionStarted,
|
||||
})
|
||||
posthog.capture('transcript-started', {})
|
||||
posthog.capture('transcript-started', {
|
||||
includeScreenRecording: includeScreenRecording,
|
||||
language: selectedLanguageKey,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading ||
|
||||
isRecordingTransitioning ||
|
||||
statuses.isAnotherModeStarted ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Div
|
||||
@@ -138,199 +164,101 @@ export const TranscriptSidePanel = () => {
|
||||
>
|
||||
<img
|
||||
src="/assets/intro-slider/3.png"
|
||||
alt={''}
|
||||
alt=""
|
||||
className={css({
|
||||
minHeight: '309px',
|
||||
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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{!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}
|
||||
<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}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{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('')}
|
||||
{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('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
<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}
|
||||
/>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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])
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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])
|
||||
}
|
||||
@@ -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 { RecordingStateToast } from './components/RecordingStateToast'
|
||||
export { RecordingProvider } from './components/RecordingProvider'
|
||||
export { TranscriptSidePanel } from './components/TranscriptSidePanel'
|
||||
export { ScreenRecordingSidePanel } from './components/ScreenRecordingSidePanel'
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -228,10 +228,20 @@ export const Conference = ({
|
||||
posthog.captureException(e)
|
||||
}}
|
||||
onDisconnected={(e) => {
|
||||
if (e == DisconnectReason.CLIENT_INITIATED) {
|
||||
navigateTo('feedback', { duplicateIdentity: false })
|
||||
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
|
||||
navigateTo('feedback', { duplicateIdentity: true })
|
||||
switch (e) {
|
||||
case DisconnectReason.CLIENT_INITIATED:
|
||||
navigateTo('feedback')
|
||||
return
|
||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
{
|
||||
state: { reason: e },
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
}}
|
||||
onMediaDeviceFailure={(e, kind) => {
|
||||
|
||||
@@ -67,7 +67,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
gap={0}
|
||||
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
|
||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||
{t('heading')}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={t('copyUrl')}
|
||||
tooltip={t('copyUrl')}
|
||||
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? (
|
||||
<RiCheckLine aria-hidden="true" />
|
||||
@@ -138,11 +138,12 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
{formatPinCode(roomData?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'secondaryText'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
@@ -173,7 +174,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
aria-label={isCopied ? t('copied') : t('copy')}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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,6 +35,7 @@ export const ParticipantName = ({
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{displayedName}
|
||||
</Text>
|
||||
|
||||
@@ -29,6 +29,9 @@ 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<{
|
||||
@@ -102,9 +105,33 @@ 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' }} {...elementProps}>
|
||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
@@ -195,10 +222,16 @@ export const ParticipantTile: (
|
||||
</>
|
||||
)}
|
||||
{!disableMetadata && (
|
||||
<ParticipantTileFocus trackRef={trackReference} />
|
||||
<ParticipantTileFocus
|
||||
trackRef={trackReference}
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
/>
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
{hasKeyboardFocus && (
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -131,8 +131,10 @@ const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
hasKeyboardFocus,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
hasKeyboardFocus: boolean
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [opacity, setOpacity] = useState(0)
|
||||
@@ -140,8 +142,10 @@ export const ParticipantTileFocus = ({
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
const [isIdleRef, setIsIdleRef] = useState(false)
|
||||
|
||||
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered && !isIdleRef) {
|
||||
if (isVisible) {
|
||||
// Wait for next frame to ensure element is mounted
|
||||
requestAnimationFrame(() => {
|
||||
setOpacity(0.6)
|
||||
@@ -149,7 +153,7 @@ export const ParticipantTileFocus = ({
|
||||
} else {
|
||||
setOpacity(0)
|
||||
}
|
||||
}, [hovered, isIdleRef])
|
||||
}, [isVisible])
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (idleTimerRef.current) {
|
||||
@@ -180,11 +184,12 @@ export const ParticipantTileFocus = ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
})}
|
||||
aria-hidden={!isVisible}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{hovered && (
|
||||
{isVisible && (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
|
||||
@@ -5,6 +5,9 @@ import { css } from '@/styled-system/css'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||
import { accessibilityStore } from '@/stores/accessibility'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
export const ANIMATION_DURATION = 3000
|
||||
export const ANIMATION_DISTANCE = 300
|
||||
@@ -140,11 +143,53 @@ export function ReactionPortal({
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
|
||||
reactions.map((instance) => (
|
||||
<ReactionPortal
|
||||
key={instance.id}
|
||||
emoji={instance.emoji}
|
||||
participant={instance.participant}
|
||||
/>
|
||||
))
|
||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const { announceReactions } = useSnapshot(accessibilityStore)
|
||||
const [announcement, setAnnouncement] = useState<string | null>(null)
|
||||
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
||||
|
||||
const latestReaction =
|
||||
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!announceReactions) {
|
||||
setAnnouncement(null)
|
||||
return
|
||||
}
|
||||
if (!latestReaction) return
|
||||
const isNewReaction = latestReaction.id !== lastAnnouncedId
|
||||
if (!isNewReaction) return
|
||||
|
||||
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
|
||||
const participantName = latestReaction.participant?.isLocal
|
||||
? t('you')
|
||||
: latestReaction.participant?.name?.trim() ||
|
||||
t('someone', { defaultValue: 'Someone' })
|
||||
setAnnouncement(t('announce', { name: participantName, emoji: emojiLabel }))
|
||||
setLastAnnouncedId(latestReaction.id)
|
||||
|
||||
const timer = setTimeout(() => setAnnouncement(null), 1200)
|
||||
return () => clearTimeout(timer)
|
||||
}, [latestReaction, lastAnnouncedId, announceReactions, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
{reactions.map((instance) => (
|
||||
<ReactionPortal
|
||||
key={instance.id}
|
||||
emoji={instance.emoji}
|
||||
participant={instance.participant}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{announcement ?? ''}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,27 +13,32 @@ import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
ariaLabel: string
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
isClosed: boolean
|
||||
closeButtonTooltip: string
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
backButtonLabel: string
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
title,
|
||||
ariaLabel,
|
||||
children,
|
||||
onClose,
|
||||
isClosed,
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
backButtonLabel,
|
||||
}: StyledSidePanelProps) => (
|
||||
<div
|
||||
<aside
|
||||
className={css({
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
@@ -58,32 +63,37 @@ const StyledSidePanel = ({
|
||||
style={{
|
||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||
}}
|
||||
aria-hidden={isClosed}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<HStack alignItems="center">
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size={'sm'}
|
||||
size="sm"
|
||||
square
|
||||
className={css({ marginRight: '0.5rem' })}
|
||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||
aria-label={backButtonLabel}
|
||||
onPress={onBack}
|
||||
>
|
||||
<RiArrowLeftLine size={20} />
|
||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
{title}
|
||||
</Heading>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
</HStack>
|
||||
<Div
|
||||
position="absolute"
|
||||
top="5"
|
||||
@@ -104,7 +114,7 @@ const StyledSidePanel = ({
|
||||
</Button>
|
||||
</Div>
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
type PanelProps = {
|
||||
@@ -125,7 +135,6 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
{keepAlive || isOpen ? children : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const SidePanel = () => {
|
||||
const {
|
||||
activePanelId,
|
||||
@@ -144,6 +153,7 @@ export const SidePanel = () => {
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||
ariaLabel={t('ariaLabel')}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
@@ -153,6 +163,7 @@ export const SidePanel = () => {
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
@@ -164,7 +175,7 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isToolsOpen}>
|
||||
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||
<Tools />
|
||||
</Panel>
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { A, Div, Text } from '@/primitives'
|
||||
import { A, Div, Icon, 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 {
|
||||
@@ -21,8 +18,6 @@ export interface ToolsButtonProps {
|
||||
title: string
|
||||
description: string
|
||||
onPress: () => void
|
||||
isBetaFeature?: boolean
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const ToolButton = ({
|
||||
@@ -30,8 +25,6 @@ const ToolButton = ({
|
||||
title,
|
||||
description,
|
||||
onPress,
|
||||
isBetaFeature = false,
|
||||
isActive = false,
|
||||
}: ToolsButtonProps) => {
|
||||
return (
|
||||
<RACButton
|
||||
@@ -42,9 +35,9 @@ const ToolButton = ({
|
||||
justifyContent: 'start',
|
||||
paddingY: '0.5rem',
|
||||
paddingX: '0.75rem 1.5rem',
|
||||
borderRadius: '5px',
|
||||
gap: '1.25rem',
|
||||
borderRadius: '30px',
|
||||
width: 'full',
|
||||
backgroundColor: 'gray.50',
|
||||
textAlign: 'start',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primary.50',
|
||||
@@ -55,81 +48,78 @@ const ToolButton = ({
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '50px',
|
||||
minWidth: '50px',
|
||||
height: '40px',
|
||||
minWidth: '40px',
|
||||
borderRadius: '25px',
|
||||
backgroundColor: 'primary.800',
|
||||
marginRight: '0.75rem',
|
||||
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 })}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: 0.25,
|
||||
fontWeight: 'semibold',
|
||||
})}
|
||||
>
|
||||
{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 } =
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
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 isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
|
||||
const isScreenRecordingActive = useIsRecordingActive(
|
||||
const isScreenRecordingEnabled = useIsRecordingModeEnabled(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
|
||||
@@ -150,6 +140,7 @@ export const Tools = () => {
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
gap={0.5}
|
||||
>
|
||||
<Text
|
||||
variant="note"
|
||||
@@ -157,8 +148,8 @@ export const Tools = () => {
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
paddingX: '0.75rem',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
margin="md"
|
||||
>
|
||||
{t('body')}{' '}
|
||||
{data?.support?.help_article_more_tools && (
|
||||
@@ -172,22 +163,18 @@ export const Tools = () => {
|
||||
</Text>
|
||||
{isTranscriptEnabled && (
|
||||
<ToolButton
|
||||
icon={<RiFileTextFill size={24} color="white" />}
|
||||
icon={<Icon type="symbols" name="speech_to_text" />}
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={() => openTranscript()}
|
||||
isBetaFeature
|
||||
isActive={isTranscriptActive}
|
||||
/>
|
||||
)}
|
||||
{hasScreenRecordingAccess && (
|
||||
{isScreenRecordingEnabled && (
|
||||
<ToolButton
|
||||
icon={<RiLiveFill size={24} color="white" />}
|
||||
icon={<Icon type="symbols" name="mode_standby" />}
|
||||
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' ? 'Space' : undefined,
|
||||
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
isDisabled: cannotUseDevice,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const OptionsButton = () => {
|
||||
return (
|
||||
<Menu variant="dark">
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
|
||||
+21
-15
@@ -1,6 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
@@ -19,6 +20,8 @@ export const ParticipantsToggle = ({
|
||||
*/
|
||||
const participants = useParticipants()
|
||||
const numParticipants = participants?.length
|
||||
const announcedCount =
|
||||
numParticipants && numParticipants > 0 ? numParticipants : 1
|
||||
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
|
||||
@@ -31,21 +34,24 @@ export const ParticipantsToggle = ({
|
||||
display: 'inline-block',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isParticipantsOpen}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-participants-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</ToggleButton>
|
||||
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
aria-label={`${t(tooltipLabel)}. ${t('count', {
|
||||
count: announcedCount,
|
||||
})}.`}
|
||||
isSelected={isParticipantsOpen}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-participants-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiGroupLine />
|
||||
</ToggleButton>
|
||||
</VisualOnlyTooltip>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ANIMATION_DURATION,
|
||||
ReactionPortals,
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
||||
import { Participant } from 'livekit-client'
|
||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||
@@ -145,7 +146,7 @@ export const ReactionsToggle = () => {
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => debouncedSendReaction(emoji)}
|
||||
aria-label={t('send', { emoji })}
|
||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
square
|
||||
|
||||
+185
-85
@@ -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,11 +50,17 @@ export const EffectsConfiguration = ({
|
||||
layout = 'horizontal',
|
||||
}: EffectsConfigurationProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const blurLightRef = useRef<HTMLButtonElement | null>(null)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
||||
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
||||
const [effectStatusMessage, setEffectStatusMessage] = useState('')
|
||||
const effectAnnouncementTimeout = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null)
|
||||
const effectAnnouncementId = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
@@ -69,16 +75,95 @@ export const EffectsConfiguration = ({
|
||||
}
|
||||
}, [videoTrack, videoTrack?.isMuted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!blurLightRef.current) return
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
blurLightRef.current?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const announceEffectStatusMessage = (message: string) => {
|
||||
effectAnnouncementId.current += 1
|
||||
const currentId = effectAnnouncementId.current
|
||||
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
|
||||
// Clear the region first so screen readers drop queued announcements.
|
||||
setEffectStatusMessage('')
|
||||
|
||||
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== effectAnnouncementId.current) return
|
||||
setEffectStatusMessage(message)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
const clearEffect = async () => {
|
||||
await videoTrack.stopProcessor()
|
||||
onSubmit?.(undefined)
|
||||
}
|
||||
|
||||
const getVirtualBackgroundName = (imagePath?: string) => {
|
||||
if (!imagePath) return ''
|
||||
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
|
||||
if (!match) return ''
|
||||
const index = Number(match[1]) - 1
|
||||
if (Number.isNaN(index)) return ''
|
||||
return t(`virtual.descriptions.${index}`)
|
||||
}
|
||||
|
||||
const updateEffectStatusMessage = (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions,
|
||||
wasSelectedBeforeToggle: boolean
|
||||
) => {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
announceEffectStatusMessage(t('blur.status.none'))
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ProcessorType.BLUR) {
|
||||
const message =
|
||||
options.blurRadius === BlurRadius.LIGHT
|
||||
? t('blur.status.light')
|
||||
: t('blur.status.strong')
|
||||
announceEffectStatusMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ProcessorType.VIRTUAL) {
|
||||
const backgroundName = getVirtualBackgroundName(options.imagePath)
|
||||
if (backgroundName) {
|
||||
announceEffectStatusMessage(
|
||||
`${t('virtual.selectedLabel')} ${backgroundName}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEffect = async (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions
|
||||
) => {
|
||||
setProcessorPending(true)
|
||||
const wasSelectedBeforeToggle = isSelected(type, options)
|
||||
|
||||
if (!videoTrack) {
|
||||
/**
|
||||
* Special case: if no video track is available, then we must pass directly the processor into the
|
||||
@@ -104,7 +189,7 @@ export const EffectsConfiguration = ({
|
||||
|
||||
const processor = getProcessor()
|
||||
try {
|
||||
if (isSelected(type, options)) {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
// Stop processor.
|
||||
await clearEffect()
|
||||
} else if (
|
||||
@@ -131,6 +216,8 @@ export const EffectsConfiguration = ({
|
||||
// We want to trigger onSubmit when options changes so the parent component is aware of it.
|
||||
onSubmit?.(processor)
|
||||
}
|
||||
|
||||
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
@@ -153,8 +240,28 @@ export const EffectsConfiguration = ({
|
||||
)
|
||||
}
|
||||
|
||||
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
|
||||
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}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -265,65 +372,60 @@ export const EffectsConfiguration = ({
|
||||
>
|
||||
{t('blur.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={t('clear')}
|
||||
onPress={async () => {
|
||||
await clearEffect()
|
||||
}}
|
||||
isSelected={!getProcessor()}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<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, {
|
||||
<ToggleButton
|
||||
ref={blurLightRef}
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})
|
||||
}
|
||||
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, {
|
||||
})}
|
||||
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, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
data-attr="toggle-blur-normal"
|
||||
>
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
})}
|
||||
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
data-attr="toggle-blur-normal"
|
||||
>
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{effectStatusMessage}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
@@ -343,39 +445,37 @@ 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 (
|
||||
<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, {
|
||||
<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, {
|
||||
imagePath,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
</VisualOnlyTooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
formatChatMessageLinks,
|
||||
useChat,
|
||||
@@ -15,6 +15,7 @@ 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>,
|
||||
@@ -36,6 +37,19 @@ 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()
|
||||
|
||||
@@ -45,7 +59,7 @@ export function Chat({ ...props }: ChatProps) {
|
||||
async function handleSubmit(text: string) {
|
||||
if (!send || !text) return
|
||||
await send(text)
|
||||
inputRef?.current?.focus()
|
||||
inputRef?.current?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||
|
||||
@@ -11,6 +11,7 @@ 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'
|
||||
|
||||
@@ -19,6 +20,18 @@ 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,6 +73,7 @@ export function MobileControlBar({
|
||||
/>
|
||||
<HandToggle />
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
|
||||
@@ -37,6 +37,7 @@ 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 { RecordingStateToast } from '@/features/recording'
|
||||
import { RecordingProvider } 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 />
|
||||
<RecordingStateToast />
|
||||
<RecordingProvider />
|
||||
<SettingsDialogProvider />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const getEmojiLabel = (
|
||||
emoji: string,
|
||||
t: ReturnType<typeof useTranslation>['t']
|
||||
) => {
|
||||
const emojiLabels: Record<string, string> = {
|
||||
'thumbs-up': t('emojis.thumbs-up', { defaultValue: 'thumbs up' }),
|
||||
'thumbs-down': t('emojis.thumbs-down', { defaultValue: 'thumbs down' }),
|
||||
'clapping-hands': t('emojis.clapping-hands', {
|
||||
defaultValue: 'clapping hands',
|
||||
}),
|
||||
'red-heart': t('emojis.red-heart', { defaultValue: 'red heart' }),
|
||||
'face-with-tears-of-joy': t('emojis.face-with-tears-of-joy', {
|
||||
defaultValue: 'face with tears of joy',
|
||||
}),
|
||||
'face-with-open-mouth': t('emojis.face-with-open-mouth', {
|
||||
defaultValue: 'surprised face',
|
||||
}),
|
||||
'party-popper': t('emojis.party-popper', { defaultValue: 'party popper' }),
|
||||
'folded-hands': t('emojis.folded-hands', { defaultValue: 'folded hands' }),
|
||||
}
|
||||
return emojiLabels[emoji] ?? emoji
|
||||
}
|
||||
@@ -3,7 +3,9 @@ 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, useSearchParams } from 'wouter'
|
||||
import { useLocation } from 'wouter'
|
||||
import { useMemo } from 'react'
|
||||
import { DisconnectReason } from 'livekit-client'
|
||||
|
||||
// fixme - duplicated with home, refactor in a proper style
|
||||
const Heading = styled('h1', {
|
||||
@@ -20,25 +22,40 @@ const Heading = styled('h1', {
|
||||
},
|
||||
})
|
||||
|
||||
enum DisconnectReasonKey {
|
||||
DuplicateIdentity = 'duplicateIdentity',
|
||||
ParticipantRemoved = 'participantRemoved',
|
||||
}
|
||||
|
||||
export const FeedbackRoute = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
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
|
||||
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Center>
|
||||
<VStack>
|
||||
<Heading>
|
||||
{t(
|
||||
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
|
||||
)}
|
||||
</Heading>
|
||||
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
|
||||
<HStack>
|
||||
<Button variant="secondary" onPress={() => window.history.back()}>
|
||||
{t('feedback.back')}
|
||||
</Button>
|
||||
{showBackButton && (
|
||||
<Button variant="secondary" onPress={() => window.history.back()}>
|
||||
{t('feedback.back')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onPress={() => setLocation('/')}>
|
||||
{t('feedback.home')}
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 {
|
||||
@@ -10,15 +11,19 @@ import {
|
||||
RiSettings3Line,
|
||||
RiSpeakerLine,
|
||||
RiVideoOnLine,
|
||||
RiEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import { AccountTab } from './tabs/AccountTab'
|
||||
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'
|
||||
import AccessibilityTab from './tabs/AccessibilityTab'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -58,6 +63,8 @@ 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
|
||||
@@ -100,6 +107,18 @@ 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>
|
||||
)}
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
|
||||
<RiEyeLine />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
<div className={tabPanelContainerStyle}>
|
||||
@@ -111,6 +130,9 @@ 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} />
|
||||
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
|
||||
</div>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Field, H } from '@/primitives'
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { accessibilityStore } from '@/stores/accessibility'
|
||||
|
||||
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
|
||||
|
||||
export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const snap = useSnapshot(accessibilityStore)
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('tabs.accessibility')}</H>
|
||||
<ul
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<li>
|
||||
<Field
|
||||
type="switch"
|
||||
label={t('accessibility.announceReactions.label')}
|
||||
isSelected={snap.announceReactions}
|
||||
onChange={(value) => {
|
||||
accessibilityStore.announceReactions = value
|
||||
}}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessibilityTab
|
||||
@@ -0,0 +1,30 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user