Compare commits

..

3 Commits

Author SHA1 Message Date
lebaudantoine 27af71b9f4 🚸(frontend) explain to a user her was ejected
Add a clear feedback message explaining to users when they are ejected from a
meeting, explicitly stating that the action was taken by an admin.
2026-01-11 11:23:29 +01:00
lebaudantoine e88c80044a ♻️(frontend) refactor disconnection reason handling and state
Refactor the duplicateIdentity boolean URL parameter into an extensible string
reason parameter, making it easier to customize the disconnection message
shown to users.

Avoid passing this value via URL parameters, which are easy to manipulate.
Instead, use Wouter’s built-in navigation state to pass data across pages.

This was initially missed because navigateTo is a wrapper around Wouter’s
official navigation function, and its arguments were easy to overlook. This is
now fixed.

This prepares the ground for supporting additional disconnection reasons in
upcoming commits.
2026-01-10 18:45:44 +01:00
lebaudantoine f886aa90c2 ♻️(frontend) refactor the onDisconnected function to use a switch
This makes the logic more extensible in preparation for introducing
additional disconnect reason handlers.
2026-01-10 18:42:56 +01:00
136 changed files with 945 additions and 3416 deletions
+4 -4
View File
@@ -152,7 +152,7 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target summary-production'
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'
-
@@ -161,7 +161,7 @@ jobs:
with:
context: ./src/summary
file: ./src/summary/Dockerfile
target: summary-production
target: production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
@@ -191,7 +191,7 @@ jobs:
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target agents-production'
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'
-
@@ -200,7 +200,7 @@ jobs:
with:
context: ./src/agents
file: ./src/agents/Dockerfile
target: agents-production
target: production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
-71
View File
@@ -8,76 +8,6 @@ and this project adheres to
## [Unreleased]
## [1.7.0] - 2026-02-19
### Added
- ✨(frontend) expose Windows app web link #976
- ✨(frontend) support additional shortcuts to broaden accessibility
### Changed
- ✨(frontend) add clickable settings general link in idle modal #974
## [1.6.0] - 2026-02-10
### Added
- ✨(backend) monitor throttling rate failure through sentry #964
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
### Changed
- ♿️(frontend) improve spinner reducedmotion fallback #931
- ♿️(frontend) fix form labels and autocomplete wiring #932
- 🥅(summary) catch file-related exceptions when handling recording #944
- 📝(frontend) update legal terms #956
- ⚡️(backend) enhance django admin's loading performance #954
- 🌐(frontend) add missing DE translation for accessibility settings
### Fixed
- 🔐(backend) enforce object-level permission checks on room endpoint #959
- 🔒️(backend) add application validation when consuming external JWT #963
## [1.5.0] - 2026-01-28
### Changed
- ♿️(frontend) adjust visual-only tooltip a11y labels #910
- ♿️(frontend) sr pin/unpin announcements with dedicated messages #898
- ♿(frontend) adjust sr announcements for idle disconnect timer #908
- ♿️(frontend) add global screen reader announcer#922
### Fixed
- 🔒️(frontend) fix an XSS vulnerability on the recording page #911
## [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
@@ -96,7 +26,6 @@ and this project adheres to
### 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
+1 -1
View File
@@ -4,7 +4,7 @@
FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
RUN python -m pip install --upgrade pip setuptools
# Upgrade system packages to install security updates
RUN apk update && \
+14 -14
View File
@@ -40,9 +40,9 @@ DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-development
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-development
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
@@ -94,8 +94,8 @@ build: ## build the project containers
@$(MAKE) build-frontend
.PHONY: build
build-backend: ## build the app-development container
@$(COMPOSE) build app-development
build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev
.PHONY: build-backend
@@ -107,12 +107,12 @@ down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
logs: ## display app-development logs (follow mode)
@$(COMPOSE) logs -f app-development
logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run-backend: ## start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-development
@$(COMPOSE) up --force-recreate -d celery-dev
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run-backend
@@ -227,14 +227,14 @@ back-i18n-generate: ## create the .pot files used for i18n
@$(MANAGE) makemessages -a --keep-pot
.PHONY: back-i18n-generate
shell: ## open a Django Python shell
shell: ## connect to database shell
@$(MANAGE) shell #_plus
.PHONY: shell
.PHONY: dbshell
# -- Database
dbshell: ## connect to database shell
docker compose exec app-development python manage.py dbshell
docker compose exec app-dev python manage.py dbshell
.PHONY: dbshell
resetdb: FLUSH_ARGS ?=
@@ -348,16 +348,16 @@ build-k8s-cluster: ## build the kubernetes cluster using kind
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: install-external-secrets
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: start-tilt
.PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: start-tilt-keycloak
.PHONY: build-k8s-cluster
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
.PHONY: start-tilt-dinum
.PHONY: build-k8s-cluster
-2
View File
@@ -1,2 +0,0 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate
+1 -4
View File
@@ -50,9 +50,6 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
### 🚀 Major roll out to all French public servants
On the 25th of January 2026, David Amiel, Frances Minister for Civil Service and State Reform, announced the full deployment of Visio—the French governments dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
## Table of Contents
@@ -89,7 +86,7 @@ We hope to see many more, here is an incomplete list of public La Suite Meet ins
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing
+2 -2
View File
@@ -59,7 +59,7 @@ docker_build(
context='../src/summary',
dockerfile='../src/summary/Dockerfile',
only=['.'],
target = 'summary-production',
target = 'production',
live_update=[
sync('../src/summary', '/app'),
]
@@ -71,7 +71,7 @@ docker_build(
context='../src/agents',
dockerfile='../src/agents/Dockerfile',
only=['.'],
target = 'agents-production',
target = 'production',
live_update=[
sync('../src/agents', '/app'),
]
+1 -1
View File
@@ -89,5 +89,5 @@ function _dc_exec() {
#
# ARGS : django's manage.py command arguments
function _django_manage() {
_dc_run "app-development" python manage.py "$@"
_dc_run "app-dev" python manage.py "$@"
}
-9
View File
@@ -1,9 +0,0 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-compile script"
# Cleanup
rm -rf docker docs env.d gitlint
-50
View File
@@ -1,50 +0,0 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
mkdir -p build/
mv src/frontend/dist build/frontend-out
ASSETS_DIR=build/frontend-out/assets
if [ -n "$CUSTOM_LOGO_URL" ]; then
# Ensure https
[[ ! "$CUSTOM_LOGO_URL" =~ ^https:// ]] && echo "[custom-logo] ERROR: URL must use HTTPS" >&2 && exit 1
# Prevent SSRF
HOSTNAME=$(echo "$CUSTOM_LOGO_URL" | sed -E 's|^https://([^/:]+).*|\1|')
[[ "$HOSTNAME" =~ ^(localhost|127\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|0\.0\.0\.0|\[::1\]) ]] && echo "[custom-logo] ERROR: SSRF blocked: $HOSTNAME" >&2 && exit 1
LOGO_FILE="${ASSETS_DIR}/logo.svg"
TMP_FILE=$(mktemp "${LOGO_FILE}.XXXXXX.tmp")
# Actual download
echo "[custom-logo] INFO: Downloading custom logo from: $CUSTOM_LOGO_URL"
curl -fsSL --tlsv1.2 -o "$TMP_FILE" "$CUSTOM_LOGO_URL"
# Validate filesize
FILESIZE=$(stat -c%s "$TMP_FILE" 2>/dev/null || stat -f%z "$TMP_FILE")
[[ "$FILESIZE" -eq 0 ]] && echo "[custom-logo] ERROR: empty file" >&2 && exit 1
[[ "$FILESIZE" -gt 5242880 ]] && echo "[custom-logo] ERROR: file too large (${FILESIZE}B > 5MB)" >&2 && exit 1
# Validate file type
IS_SVG=false
HEADER=$(head -c 100 "$TMP_FILE" | tr -d '\0' | tr '[:upper:]' '[:lower:]')
[[ "$HEADER" =~ ^.*"<svg".*$ ]] && IS_SVG=true
[[ "$HEADER" =~ ^.*"<?xml".*"<svg".*$ ]] && IS_SVG=true
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
mv -f "$TMP_FILE" "$LOGO_FILE"
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
fi
mv src/backend/* ./
mv deploy/paas/* ./
echo "3.13" > .python-version
echo "." > requirements.txt
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
# Start the Django backend server
gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
# Start the Nginx server
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
# then kill all the other tasks
pkill -P $$
+1 -1
View File
@@ -4,5 +4,5 @@ source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
-e DJANGO_CONFIGURATION=Test \
app-development \
app-dev \
pytest "$@"
+1 -1
View File
@@ -4,7 +4,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
-e DJANGO_CONFIGURATION=Test \
app-development \
app-dev \
python manage.py spectacular \
--api-version 'v1.0' \
--urlconf 'meet.api_urls' \
+14 -14
View File
@@ -55,13 +55,13 @@ services:
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password &&
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-development:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
exit 0;"
app-development:
app-dev:
build:
context: .
target: backend-development
@@ -94,7 +94,7 @@ services:
- resource-server
- default
celery-development:
celery-dev:
user: ${DOCKER_USER:-1000}
image: meet:backend-development
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG"]
@@ -107,9 +107,9 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
- app-development
- app-dev
app-production:
app:
build:
context: .
target: backend-production
@@ -128,7 +128,7 @@ services:
- livekit
- minio
celery-production:
celery:
user: ${DOCKER_USER:-1000}
image: meet:backend-production
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
@@ -138,7 +138,7 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
- app-production
- app
nginx:
image: nginx:1.25
@@ -161,7 +161,7 @@ services:
args:
VITE_API_BASE_URL: "http://localhost:8071"
VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-production
image: meet:frontend-development
ports:
- "3000:8080"
@@ -249,10 +249,10 @@ services:
ports:
- "6379:6379"
app-summary-development:
app-summary-dev:
build:
context: src/summary
target: summary-development
target: development
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
@@ -270,7 +270,7 @@ services:
build:
context: ./src/summary
dockerfile: Dockerfile
target: summary-production
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
env_file:
- env.d/development/summary
@@ -278,7 +278,7 @@ services:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-development
- app-summary-dev
- minio
develop:
watch:
@@ -290,7 +290,7 @@ services:
build:
context: ./src/summary
dockerfile: Dockerfile
target: summary-production
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
env_file:
- env.d/development/summary
@@ -298,7 +298,7 @@ services:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-development
- app-summary-dev
- minio
develop:
watch:
-52
View File
@@ -1,52 +0,0 @@
# ERB templated nginx configuration
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
upstream backend_server {
server localhost:8000 fail_timeout=0;
}
server {
listen <%= ENV["PORT"] %>;
server_name _;
server_tokens off;
root /app/build/frontend-out;
# Django rest framework
location ^~ /api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Django admin
location ^~ /admin/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
# Add no-cache headers
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache"; # HTTP 1.0 header for backward compatibility
add_header Expires 0;
}
# Optionally, handle 404 errors by redirecting to index.html
error_page 404 =200 /index.html;
}
@@ -1,9 +1,9 @@
upstream meet_backend {
server ${BACKEND_INTERNAL_HOST}:8000 fail_timeout=0;
server ${BACKEND_HOST}:8000 fail_timeout=0;
}
upstream meet_frontend {
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
server ${FRONTEND_HOST}:8080 fail_timeout=0;
}
server {
@@ -3,8 +3,3 @@ redis:
address: redis:6379
keys:
devkey: secret
webhook:
api_key: devkey
urls:
- http://app-development:8000/api/v1.0/rooms/webhooks-livekit/
+2 -3
View File
@@ -10,7 +10,7 @@ services:
- env.d/postgresql
- env.d/common
volumes:
- ./data/databases/backend:/var/lib/postgresql/data
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:5
@@ -20,8 +20,8 @@ services:
user: ${DOCKER_USER:-1000}
restart: always
env_file:
- .env
- env.d/common
- env.d/backend
- env.d/postgresql
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
@@ -45,7 +45,6 @@ services:
- /docker-entrypoint.sh
command: ["nginx", "-g", "daemon off;"]
env_file:
- .env
- env.d/common
# Uncomment and set your values if using our nginx proxy example
# environment:
+2 -2
View File
@@ -8,7 +8,7 @@
### Step 1: Prepare your working environment:
```bash
mkdir -p keycloak/env.d && cd keycloak
mkdir keycloak/env.d && cd keycloak
curl -o compose.yaml https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/docs/examples/compose/keycloak/compose.yaml
curl -o env.d/kc_postgresql https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/kc_postgresql
curl -o env.d/keycloak https://raw.githubusercontent.com/suitenumerique/meet/refs/heads/main/env.d/production.dist/keycloak
@@ -89,4 +89,4 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
#### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
2. Copy the client ID (`meet` in this example) and the client secret.
-4
View File
@@ -9,10 +9,6 @@ La Suite Meet maintainers use only the Kubernetes deployment method in productio
We understand that not everyone has a Kubernetes cluster available, please follow the instructions provided [here](/docs/installation/compose.md) to set up a docker compose instance.
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
## Scalingo
La Suite Meet can be deployed on Scalingo PaaS using the Suite Numérique buildpack. See the [Scalingo deployment guide](./scalingo.md) for detailed instructions.
## Other ways to install La Suite Meet
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
+4 -13
View File
@@ -1,6 +1,6 @@
# Installation with docker compose
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
## Requirements
@@ -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](../../src/helm/meet/README.md).
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
In this example, we assume the following services:
@@ -129,7 +129,7 @@ The following ports will need to be opened:
- 7881/tcp - WebRTC ICE over TCP
- 7882/udp - for WebRTC multiplexing over UDP
If you are using ufw, enter the following:
If you are using ufw, enter the follwoing:
```
ufw allow 80/tcp
ufw allow 443/tcp
@@ -177,15 +177,6 @@ You will need to uncomment the environment and network sections in compose file
# external: true
```
#### Caddy Reverse Proxy
Expose the Frontend port to the host
```yaml
frontend:
ports:
- "8086:8086"
```
## Step 5: Start Meet
You are ready to start your Meet application !
@@ -207,7 +198,7 @@ Replace `<admin email>` with the email of your admin user and generate a secure
Your Meet instance is now available on the domain you defined, https://meet.yourdomain.tld.
The admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
THe admin interface is available on https://meet.yourdomain.tld/admin with the admin user you just created.
## How to upgrade your Meet application
-185
View File
@@ -1,185 +0,0 @@
# Deployment on Scalingo
This guide explains how to deploy La Suite Meet on [Scalingo](https://scalingo.com/) using the [Suite Numérique buildpack](https://github.com/suitenumerique/buildpack).
## Overview
Scalingo is a Platform-as-a-Service (PaaS) that simplifies application deployment. This setup uses a custom buildpack to handle both the frontend (Vite) and backend (Django) builds, serving them through Nginx.
## Prerequisites
- A Scalingo account
- Scalingo CLI installed (optional but recommended)
- A PostgreSQL database addon
- A Redis addon (for caching and sessions)
## Step 1: Create Your App
Create a new app on Scalingo using `scalingo` cli or using the [Scalingo dashboard](https://dashboard.scalingo.com/).
## Step 2: Provision Addons
Add the required PostgreSQL and Redis services.
This will set the following environment variables automatically:
- `SCALINGO_POSTGRESQL_URL` - Database connection string
- `SCALINGO_REDIS_URL` - Redis connection string
## Step 3: Configure Environment Variables
Set the following environment variables in your Scalingo app:
### Buildpack Configuration
```bash
scalingo env-set BUILDBACK_URL="https://github.com/suitenumerique/buildpack#main"
scalingo env-set LASUITE_APP_NAME="meet"
scalingo env-set LASUITE_BACKEND_DIR="."
scalingo env-set LASUITE_FRONTEND_DIR="src/frontend/"
scalingo env-set LASUITE_NGINX_DIR="."
scalingo env-set LASUITE_SCRIPT_POSTCOMPILE="bin/buildpack_postcompile.sh"
scalingo env-set LASUITE_SCRIPT_POSTFRONTEND="bin/buildpack_postfrontend.sh"
```
### Database and Cache
```bash
scalingo env-set DATABASE_URL="\$SCALINGO_POSTGRESQL_URL"
scalingo env-set REDIS_URL="\$SCALINGO_REDIS_URL"
```
### Django Settings
```bash
scalingo env-set DJANGO_SETTINGS_MODULE="meet.settings"
scalingo env-set DJANGO_CONFIGURATION="Production"
scalingo env-set DJANGO_SECRET_KEY="<generate-a-secure-secret-key>"
scalingo env-set DJANGO_ALLOWED_HOSTS="my-meet-app.osc-fr1.scalingo.io"
```
### OIDC Authentication
Configure your OIDC provider (e.g., Keycloak, Authentik):
```bash
scalingo env-set OIDC_OP_BASE_URL="https://auth.yourdomain.com/realms/meet"
scalingo env-set OIDC_RP_CLIENT_ID="meet-client-id"
scalingo env-set OIDC_RP_CLIENT_SECRET="<your-client-secret>"
scalingo env-set OIDC_RP_SIGN_ALGO="RS256"
```
### LiveKit Configuration
Meet requires a LiveKit server for video conferencing:
```bash
scalingo env-set LIVEKIT_API_URL="wss://livekit.yourdomain.com"
scalingo env-set LIVEKIT_API_KEY="<your-livekit-api-key>"
scalingo env-set LIVEKIT_API_SECRET="<your-livekit-api-secret>"
```
### Email Configuration (Optional)
For email notifications see https://doc.scalingo.com/platform/app/sending-emails:
```bash
scalingo env-set DJANGO_EMAIL_HOST="smtp.example.org"
scalingo env-set DJANGO_EMAIL_PORT="587"
scalingo env-set DJANGO_EMAIL_HOST_USER="<smtp-user>"
scalingo env-set DJANGO_EMAIL_HOST_PASSWORD="<smtp-password>"
scalingo env-set DJANGO_EMAIL_USE_TLS="True"
scalingo env-set DJANGO_EMAIL_FROM="meet@yourdomain.com"
```
## Step 4: Deploy
Deploy your application:
```bash
git push scalingo main
```
The Procfile will automatically:
1. Build the frontend (Vite)
2. Build the backend (Django)
3. Run the post-compile script (cleanup)
4. Run the post-frontend script (move assets and prepare for deployment)
5. Start Nginx and Gunicorn
6. Run django migrations
## Step 5: Create superuser
After the first deployment, create an admin user:
```bash
scalingo run python manage.py createsuperuser
```
## Custom Domain (Optional)
To use a custom domain:
1. Add the domain in Scalingo dashboard
2. Update `DJANGO_ALLOWED_HOSTS` with your custom domain
3. Configure your DNS to point to Scalingo
```bash
scalingo domains-add meet.yourdomain.com
scalingo env-set DJANGO_ALLOWED_HOSTS="meet.yourdomain.com,my-meet-app.osc-fr1.scalingo.io"
```
## Custom Logo (Optional)
To use a custom logo, set the `CUSTOM_LOGO_URL` environment variable with an HTTPS URL pointing to an SVG item (max 5MB):
```bash
scalingo env-set CUSTOM_LOGO_URL="https://cdn.yourdomain.com/logo.svg"
```
## Troubleshooting
### Check Logs
```bash
scalingo logs --tail
```
### Common Issues
1. **Build fails**: Check that all required environment variables are set
2. **Database connection error**: Verify `DATABASE_URL` is correctly set to `$SCALINGO_POSTGRESQL_URL`
3. **Static files not served**: Ensure the buildpack post-frontend script ran successfully
4. **OIDC errors**: Verify your OIDC provider configuration and callback URLs
### Useful Commands
```bash
# Open a console
scalingo run bash
# Restart the app
scalingo restart
# Scale containers
scalingo scale web:2
# One-off command
scalingo run python manage.py shell
```
## Architecture
On Scalingo, the application runs as follows:
1. **Build Phase**: The buildpack compiles both frontend and backend
2. **Runtime**:
- Nginx serves static files and proxies to the backend
- Gunicorn runs the Django WSGI application
- Both processes are managed by the `bin/buildpack_start.sh` script
## Additional Resources
- [Scalingo Documentation](https://doc.scalingo.com/)
- [Suite Numérique Buildpack](https://github.com/suitenumerique/buildpack)
- [Meet Environment Variables](../../src/helm/meet/README.md)
- [Django Configurations Documentation](https://django-configurations.readthedocs.io/)
+1 -1
View File
@@ -61,7 +61,7 @@ ALLOW_UNREGISTERED_ROOMS=False
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-development:8000/api/v1/tasks/
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
+1 -1
View File
@@ -1,4 +1,4 @@
APP_NAME="meet-app-summary-development"
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
+1 -1
View File
@@ -20,7 +20,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_LOGO_IMG="https://${MEET_HOST}/assets/logo-suite-numerique.png"
DJANGO_EMAIL_LOGO_IMG="https://${meet_HOST}/assets/logo-suite-numerique.png"
# Backend url
MEET_BASE_URL="https://${MEET_HOST}"
+1 -1
View File
@@ -15,7 +15,7 @@ COPY pyproject.toml .
RUN mkdir /install && \
pip install --prefix=/install .
FROM base AS agents-production
FROM base AS production
WORKDIR /app
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.7.0"
version = "1.2.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.3.10",
+2 -17
View File
@@ -115,10 +115,6 @@ class RoomAdmin(admin.ModelAdmin):
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
return super().get_queryset(request).prefetch_related("accesses__user")
def get_owner(self, obj):
"""Return the owner of the room for display in the admin list."""
@@ -142,7 +138,6 @@ class RecordingAccessInline(admin.TabularInline):
model = models.RecordingAccess
extra = 0
autocomplete_fields = ["user"]
@admin.action(description=_("Resend notification to external service"))
@@ -212,18 +207,8 @@ class RecordingAdmin(admin.ModelAdmin):
"created_at",
"worker_id",
)
list_filter = ["created_at"]
list_select_related = ("room",)
readonly_fields = (
"id",
"created_at",
"options",
"mode",
"room",
"status",
"updated_at",
"worker_id",
)
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
actions = [resend_notification]
def get_queryset(self, request):
-26
View File
@@ -1,26 +0,0 @@
"""Throttling modules for the API."""
from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle
from sentry_sdk import capture_message
def sentry_monitoring_throttle_failure(message):
"""Log when a failure occurs to detect rate limiting issues."""
capture_message(message, "warning")
class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
+16 -4
View File
@@ -10,7 +10,7 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from rest_framework import decorators, mixins, pagination, viewsets
from rest_framework import decorators, mixins, pagination, throttling, viewsets
from rest_framework import (
exceptions as drf_exceptions,
)
@@ -58,7 +58,7 @@ from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers, throttling
from . import permissions, serializers
from .feature_flag import FeatureFlag
# pylint: disable=too-many-ancestors
@@ -191,6 +191,18 @@ class UserViewSet(
)
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
@@ -367,7 +379,7 @@ class RoomViewSet(
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
throttle_classes=[RequestEntryAnonRateThrottle],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
@@ -477,7 +489,7 @@ class RoomViewSet(
methods=["post"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
throttle_classes=[CreationCallbackAnonRateThrottle],
)
def creation_callback(self, request):
"""Retrieve cached room data via an unauthenticated request with a unique ID.
@@ -10,8 +10,6 @@ import jwt as pyJwt
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
from rest_framework import authentication, exceptions
from core.models import Application
User = get_user_model()
logger = logging.getLogger(__name__)
@@ -96,18 +94,6 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
logger.warning("Missing 'client_id' in JWT payload")
raise exceptions.AuthenticationFailed("Invalid token claims.")
try:
application = Application.objects.get(client_id=client_id)
except Application.DoesNotExist as e:
logger.warning("Application not found: %s", client_id)
raise exceptions.AuthenticationFailed("Application not found.") from e
if not application.active:
logger.warning(
"Inactive application attempted authentication: %s", client_id
)
raise exceptions.AuthenticationFailed("Application is disabled.")
if not is_delegated:
logger.warning("Token is not marked as delegated")
raise exceptions.AuthenticationFailed("Invalid token type.")
+5 -27
View File
@@ -33,11 +33,12 @@ class BaseScopePermission(permissions.BasePermission):
Raises:
PermissionDenied: If required scope is missing from token
"""
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
# Get the current action (e.g., 'list', 'create')
action = getattr(view, "action", None)
if not action:
# DRF routers return a 405 for unsupported methods
return True
raise exceptions.PermissionDenied(
"Insufficient permissions. Unknown action."
)
required_scope = self.scope_map.get(action)
if not required_scope:
@@ -56,12 +57,9 @@ class BaseScopePermission(permissions.BasePermission):
if isinstance(token_scopes, str):
token_scopes = token_scopes.split()
# Ensure scopes is a deduplicated list (preserving order) and lowercase all scopes
token_scopes = list(dict.fromkeys(scope.lower() for scope in token_scopes))
if settings.OIDC_RS_SCOPES_PREFIX:
token_scopes = [
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
for scope in token_scopes
]
@@ -84,23 +82,3 @@ class HasRequiredRoomScope(BaseScopePermission):
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
"destroy": models.ApplicationScope.ROOMS_DELETE,
}
class RoomPermissions(permissions.BasePermission):
"""Permissions applying to the room API endpoint."""
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Enforce role-based access: read=any role, delete=owner, write=admin or owner."""
user = request.user
if request.method in permissions.SAFE_METHODS:
return obj.has_any_role(user)
if request.method == "DELETE":
return obj.is_owner(user)
return obj.is_administrator_or_owner(user)
+4 -10
View File
@@ -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 SuspiciousOperation, ValidationError
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
import jwt
@@ -28,7 +28,7 @@ from . import authentication, permissions, serializers
logger = getLogger(__name__)
class ApplicationViewSet(viewsets.ViewSet):
class ApplicationViewSet(viewsets.GenericViewSet):
"""API endpoints for application authentication and token generation."""
@decorators.action(
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.ViewSet):
)
try:
user = models.User.objects.get(email__iexact=email)
user = models.User.objects.get(email=email)
except models.User.DoesNotExist as e:
if (
settings.APPLICATION_ALLOW_USER_CREATION
@@ -123,10 +123,6 @@ class ApplicationViewSet(viewsets.ViewSet):
)
else:
raise drf_exceptions.NotFound("User not found.") from e
except models.User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
) from e
now = datetime.now(timezone.utc)
scope = " ".join(application.scopes or [])
@@ -182,9 +178,7 @@ class RoomViewSet(
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated
& permissions.HasRequiredRoomScope
& permissions.RoomPermissions
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
-4
View File
@@ -292,10 +292,6 @@ class Resource(BaseModel):
role = RoleChoices.MEMBER
return role
def has_any_role(self, user):
"""Check if a user has any role on the resource."""
return self.get_role(user) is not None
def is_administrator_or_owner(self, user):
"""
Check if a user is administrator or owner of the resource."""
@@ -102,7 +102,6 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
settings.RECORDING_DOWNLOAD_BASE_URL = None
settings.EMAIL_FROM = "notifications@acme.com"
recording = factories.RecordingFactory(room__name="Conference Room A")
+17 -635
View File
@@ -2,21 +2,21 @@
Tests for external API /room endpoint
"""
# pylint: disable=W0621,C0302
# pylint: disable=W0621
import uuid
from datetime import datetime, timedelta, timezone
from unittest import mock
from django.conf import settings
import jwt
import pytest
import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient
from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.factories import (
RoomFactory,
UserFactory,
)
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
pytestmark = pytest.mark.django_db
@@ -27,14 +27,12 @@ def generate_test_token(user, scopes):
now = datetime.now(timezone.utc)
scope_string = " ".join(scopes)
application = ApplicationFactory()
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
"client_id": str(application.client_id),
"client_id": "test-client-id",
"scope": scope_string,
"user_id": str(user.id),
"delegated": True,
@@ -55,23 +53,6 @@ def test_api_rooms_list_requires_authentication():
assert response.status_code == 401
def test_api_rooms_list_inactive_user(settings):
"""List should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
RoomFactory(users=[(user1, RoleChoices.OWNER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_list_with_valid_token(settings):
"""Listing rooms with valid token should succeed."""
@@ -92,24 +73,6 @@ def test_api_rooms_list_with_valid_token(settings):
assert response.data["results"][0]["id"] == str(room.id)
def test_api_rooms_list_with_no_rooms(settings):
"""Listing rooms with a valid token returns an empty list when there are no rooms."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Generate valid token
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
assert response.data["count"] == 0
assert response.data["results"] == []
def test_api_rooms_list_with_expired_token(settings):
"""Listing rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
@@ -129,8 +92,8 @@ def test_api_rooms_list_with_expired_token(settings):
@responses.activate
def test_api_rooms_list_with_invalid_rs_token(settings):
"""Listing rooms with invalid resource server token should return 400."""
def test_api_rooms_list_with_invalid_token(settings):
"""Listing rooms with invalid token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
@@ -167,27 +130,7 @@ def test_api_rooms_list_missing_scope(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:list"
in str(response.data).lower()
)
def test_api_rooms_list_no_scope(settings):
"""Listing rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
def test_api_rooms_list_filters_by_user(settings):
@@ -201,9 +144,7 @@ def test_api_rooms_list_filters_by_user(settings):
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
token = generate_test_token(
user1, [ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE]
)
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
@@ -217,84 +158,6 @@ def test_api_rooms_list_filters_by_user(settings):
assert str(room2.id) not in returned_ids
def test_api_rooms_retrieve_requires_authentication(settings):
"""Retrieving rooms without authentication should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
client = APIClient()
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 401
def test_api_rooms_retrieve_inactive_user(settings):
"""Retrieve should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_retrieve_with_expired_token(settings):
"""Retrieving rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_retrieve_with_invalid_rs_token(settings):
"""Retrieving rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_retrieve_requires_scope(settings):
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
@@ -315,24 +178,6 @@ def test_api_rooms_retrieve_requires_scope(settings):
)
def test_api_rooms_retrieve_no_scope(settings):
"""Retrieving rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_retrieve_success(settings):
"""Retrieving a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
@@ -367,132 +212,6 @@ def test_api_rooms_retrieve_success(settings):
}
def test_api_rooms_retrieve_success_by_user(settings):
"""Retrieve should only return rooms accessible to the authenticated user."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory()
user2 = UserFactory()
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
room4 = RoomFactory(users=[(user1, RoleChoices.ADMIN)])
token = generate_test_token(
user1, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_LIST]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room2.id}/")
assert response.status_code == 403
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room1.id}/")
assert response.status_code == 200
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room3.id}/")
assert response.status_code == 200
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room4.id}/")
assert response.status_code == 200
def test_api_rooms_retrieve_not_found(settings):
"""Retrieving a non-existing room with correct scope should return a 404."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
assert response.status_code == 404
assert "no room matches the given query." in str(response.data).lower()
def test_api_rooms_create_requires_authentication(settings):
"""Creating rooms without authentication should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
client = APIClient()
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
def test_api_rooms_create_with_expired_token(settings):
"""Creating rooms with expired token should return 401."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
user = UserFactory()
# Generate expired token
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "expired" in str(response.data).lower()
@responses.activate
def test_api_rooms_create_with_invalid_rs_token(settings):
"""Creating rooms with invalid resource server token should return 400."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": False,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
response = client.post("/external-api/v1.0/rooms/")
# Return 400 instead of 401 because ResourceServerAuthentication raises
# SuspiciousOperation when the introspected user is not active
assert response.status_code == 400
def test_api_rooms_create_inactive_user(settings):
"""Create should return 401 if user is inactive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user1 = UserFactory(is_active=False)
token = generate_test_token(user1, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user account is disabled" in str(response.data).lower()
def test_api_rooms_create_requires_scope(settings):
"""Creating a room requires ROOMS_CREATE scope."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
@@ -506,38 +225,18 @@ def test_api_rooms_create_requires_scope(settings):
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:create"
in str(response.data).lower()
assert "Insufficient permissions. Required scope: rooms:create" in str(
response.data
)
def test_api_rooms_create_no_scope(settings):
"""Creating rooms without any scope should return 403."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
# Token without scope
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post("/external-api/v1.0/rooms/")
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
def test_api_rooms_create_success(settings):
"""Creating a room with correct scope should succeed."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
)
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
@@ -546,8 +245,6 @@ def test_api_rooms_create_success(settings):
assert response.status_code == 201
assert "id" in response.data
assert "slug" in response.data
assert "name" in response.data
assert response.data["name"] == response.data["slug"]
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
@@ -555,72 +252,6 @@ def test_api_rooms_create_success(settings):
assert room.access_level == "trusted"
def test_api_rooms_create_readonly_enforcement(settings):
"""Creating a room succeeds and any provided read-only fields are ignored."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{
"id": "fake-id",
"slug": "fake-slug",
"name": "fake-name",
"access_level": "public",
},
format="json",
)
assert response.status_code == 201
assert "slug" in response.data
assert response.data["id"] != "fake-id"
assert "name" in response.data
assert response.data["slug"] != "fake-slug"
assert "id" in response.data
assert response.data["name"] != "fake-name"
# Verify room was created with user as owner
room = Room.objects.get(id=response.data["id"])
assert room.get_role(user) == RoleChoices.OWNER
assert room.access_level == "trusted"
def test_api_rooms_unknown_actions(settings):
"""Updating or deleting a room are not supported yet."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(
user,
[
ApplicationScope.ROOMS_RETRIEVE,
ApplicationScope.ROOMS_DELETE,
ApplicationScope.ROOMS_UPDATE,
],
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 405
assert 'method "delete" not allowed.' in str(response.data).lower()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 405
assert 'method "patch" not allowed.' in str(response.data).lower()
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
@@ -659,43 +290,10 @@ def test_api_rooms_response_no_telephony(settings):
assert response.data["id"] == str(room.id)
def test_api_rooms_token_scope_case_insensitive(settings):
"""Token's scope should be case-insensitive."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
# Generate token with mixed-case scope "Rooms:List" to verify that scope
# validation is case-insensitive (should match "rooms:list")
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "Rooms:List", # Mixed case - should be accepted as "rooms:list"
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 200
def test_api_rooms_token_without_delegated_flag(settings):
"""Token without delegated flag should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
@@ -704,7 +302,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"client_id": "test-client",
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": False, # Not delegated
@@ -720,73 +318,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token type." in str(response.data).lower()
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
"""Token signed with an invalid key should defer to the next authentication."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
"invalid-private-key",
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
mock_rs_authenticate.assert_called()
assert response.status_code == 401
@mock.patch.object(ResourceServerAuthentication, "authenticate", return_value=None)
def test_api_rooms_token_invalid_alg(mock_rs_authenticate, settings):
"""Token signed with an invalid alg should defer to the next authentication."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
settings.APPLICATION_JWT_ALG = "RS256"
user = UserFactory()
# Generate token without delegated flag
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "test-client",
"scope": "rooms:list",
"user_id": str(user.id),
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm="HS256", # different value
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
mock_rs_authenticate.assert_called()
assert response.status_code == 401
assert "Invalid token type." in str(response.data)
def test_api_rooms_token_missing_client_id(settings):
@@ -816,157 +348,7 @@ def test_api_rooms_token_missing_client_id(settings):
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
def test_api_rooms_token_missing_user_id(settings):
"""Token without user_id should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"scope": "rooms:list",
"delegated": True,
# Missing user_id
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token claims." in str(response.data).lower()
def test_api_rooms_token_invalid_audience(settings):
"""Token with an invalid audience should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
user = UserFactory()
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": "invalid-audience",
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(user.id),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "invalid token." in str(response.data).lower()
def test_api_rooms_token_unknown_user(settings):
"""Token for unknown user should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory()
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "user not found." in str(response.data).lower()
def test_api_rooms_token_unknown_application(settings):
"""Token for unknown application should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": "unknown-client-id",
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "application not found." in str(response.data).lower()
def test_api_rooms_token_inactive_application(settings):
"""Token for inactive application should be rejected."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
application = ApplicationFactory(active=False)
now = datetime.now(timezone.utc)
payload = {
"iss": settings.APPLICATION_JWT_ISSUER,
"aud": settings.APPLICATION_JWT_AUDIENCE,
"iat": now,
"exp": now + timedelta(hours=1),
"client_id": str(application.client_id),
"user_id": str(uuid.uuid4()),
"scope": "rooms:list",
"delegated": True,
}
token = jwt.encode(
payload,
settings.APPLICATION_JWT_SECRET_KEY,
algorithm=settings.APPLICATION_JWT_ALG,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "application is disabled." in str(response.data).lower()
assert "Invalid token claims." in str(response.data)
@responses.activate
@@ -1125,7 +507,7 @@ def test_resource_server_authentication_successful(settings):
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
"active": True,
},
)
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
UserFactory(email="User.Family@example.com")
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
@@ -40,7 +40,7 @@ def test_api_applications_generate_token_success(settings):
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user.family@example.com",
"scope": user.email,
},
format="json",
)
+1 -12
View File
@@ -18,7 +18,6 @@ from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
@@ -93,11 +92,7 @@ class Base(Configuration):
# Database
DATABASES = {
"default": dj_database_url.config()
if values.DatabaseURLValue(
None, environ_name="DATABASE_URL", environ_prefix=None
)
else {
"default": {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
@@ -297,9 +292,6 @@ class Base(Configuration):
),
},
}
MONITORED_THROTTLE_FAILURE_CALLBACK = (
"core.api.throttling.sentry_monitoring_throttle_failure"
)
SPECTACULAR_SETTINGS = {
"TITLE": "Meet API",
@@ -344,9 +336,6 @@ 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
),
+2 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "1.7.0"
version = "1.2.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -29,7 +29,6 @@ dependencies = [
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.5.3",
"dj-database-url==3.1.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.0.0",
@@ -39,7 +38,7 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.11",
"django==5.2.9",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
-6
View File
@@ -5,12 +5,6 @@ server {
root /usr/share/nginx/html;
location = /.wellknown/windows-app-web-link {
default_type application/json;
alias /usr/share/nginx/html/.wellknown/windows-app-web-link;
add_header Content-Disposition "attachment; filename=windows-app-web-link";
}
# Serve static files with caching
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
-16
View File
@@ -6,22 +6,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource/material-icons-outlined/files/material-icons-outlined-latin-400-normal.woff2"
type="font/woff2"
/>
<!-- Font URLs are resolved and replaced by Vite during the build process. Font loading failures will not break the application. -->
<link
rel="preload"
as="font"
crossorigin="anonymous"
href="/node_modules/@fontsource-variable/material-symbols-outlined/files/material-symbols-outlined-latin-wght-normal.woff2"
type="font/woff2"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
</head>
+154 -515
View File
@@ -1,42 +1,42 @@
{
"name": "meet",
"version": "1.7.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.7.0",
"version": "1.2.0",
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@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",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.9.3",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.27",
"crisp-sdk-web": "1.0.25",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "25.8.4",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.7",
"posthog-js": "1.342.1",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.3.0",
"wouter": "3.9.0"
"valtio": "2.1.5",
"wouter": "3.7.1"
},
"devDependencies": {
"@pandacss/dev": "0.54.0",
@@ -55,7 +55,7 @@
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.6",
"prettier": "3.8.1",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.0.8",
"vite-tsconfig-paths": "5.1.4"
@@ -363,9 +363,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -1021,9 +1021,9 @@
"license": "MIT"
},
"node_modules/@fontsource-variable/material-symbols-outlined": {
"version": "5.2.34",
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.34.tgz",
"integrity": "sha512-FfJVoKdYlxvIHflVZlrevf6e1WmbJGeAdbcLmOmkftoP2Du0qrRD/SmNchIXijsKXF7j3z1m++e92Qi6/U+jRA==",
"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"
@@ -1158,9 +1158,9 @@
"dev": true
},
"node_modules/@internationalized/date": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.11.0.tgz",
"integrity": "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==",
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -1177,9 +1177,9 @@
}
},
"node_modules/@internationalized/number": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz",
"integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==",
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.3.tgz",
"integrity": "sha512-p+Zh1sb6EfrfVaS86jlHGQ9HA66fJhV9x5LiE5vCbZtXEHAuhcmUZUdZ4WrFpUBfNalr2OkAJI5AcKEQF+Lebw==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -1354,252 +1354,6 @@
"node": ">= 8"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz",
"integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/core": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz",
"integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/otlp-exporter-base": "0.208.0",
"@opentelemetry/otlp-transformer": "0.208.0",
"@opentelemetry/sdk-logs": "0.208.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-exporter-base": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz",
"integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/otlp-transformer": "0.208.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz",
"integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/sdk-logs": "0.208.0",
"@opentelemetry/sdk-metrics": "2.2.0",
"@opentelemetry/sdk-trace-base": "2.2.0",
"protobufjs": "^7.3.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz",
"integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.5.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz",
"integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs": {
"version": "0.208.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz",
"integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz",
"integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.39.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz",
"integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@pandacss/config": {
"version": "0.54.0",
"resolved": "https://registry.npmjs.org/@pandacss/config/-/config-0.54.0.tgz",
@@ -1992,85 +1746,6 @@
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-0.54.0.tgz",
"integrity": "sha512-5kspg2UOgFWrawbHeoleZvbZ6id/kIBLHoDeD1CnLbl9fEbZAZ3Avi5Hi1mIFe9E8PFzp9V+N9IfQL7pYhavfA=="
},
"node_modules/@posthog/core": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.20.1.tgz",
"integrity": "sha512-uoTmWkYCtLYFpiK37/JCq+BuCA/OZn1qQZn5cPv1EEKt3ni3Zgg48xWCnSEyGFl5KKSXlfCruiRTwnbAtCgrBA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.6"
}
},
"node_modules/@posthog/types": {
"version": "1.342.1",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.342.1.tgz",
"integrity": "sha512-bcyBdO88FWTkd5AVTa4Nu8T7RfY0WJrG7WMCXum/rcvNjYhS3DmOfKf8o/Bt56vA3J3yeU0vbgrmltYVoTAfaA==",
"license": "MIT"
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1",
"@protobufjs/inquire": "^1.1.0"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@react-aria/autocomplete": {
"version": "3.0.0-beta.5",
"resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-beta.5.tgz",
@@ -2421,18 +2096,18 @@
}
},
"node_modules/@react-aria/i18n": {
"version": "3.12.15",
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.15.tgz",
"integrity": "sha512-3CrAN7ORVHrckvTmbPq76jFZabqq+rScosGT5+ElircJ5rF5+JcdT99Hp5Xg6R10jk74e8G3xiqdYsUd+7iJMA==",
"version": "3.12.10",
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.10.tgz",
"integrity": "sha512-1j00soQ2W0nTgzaaIsGFdMF/5aN60AEdCJPhmXGZiuWdWzMxObN9LQ9vdzYPTjTqyqMdSaSp9DZKs5I26Xovpw==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.11.0",
"@internationalized/date": "^3.8.2",
"@internationalized/message": "^3.1.8",
"@internationalized/number": "^3.6.5",
"@internationalized/number": "^3.6.3",
"@internationalized/string": "^3.2.7",
"@react-aria/ssr": "^3.9.10",
"@react-aria/utils": "^3.33.0",
"@react-types/shared": "^3.33.0",
"@react-aria/ssr": "^3.9.9",
"@react-aria/utils": "^3.29.1",
"@react-types/shared": "^3.30.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
@@ -2441,15 +2116,15 @@
}
},
"node_modules/@react-aria/interactions": {
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.0.tgz",
"integrity": "sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==",
"version": "3.25.3",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.3.tgz",
"integrity": "sha512-J1bhlrNtjPS/fe5uJQ+0c7/jiXniwa4RQlP+Emjfc/iuqpW2RhbF9ou5vROcLzWIyaW8tVMZ468J68rAs/aZ5A==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-aria/utils": "^3.33.0",
"@react-aria/ssr": "^3.9.9",
"@react-aria/utils": "^3.29.1",
"@react-stately/flags": "^3.1.2",
"@react-types/shared": "^3.33.0",
"@react-types/shared": "^3.30.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
@@ -2473,15 +2148,15 @@
}
},
"node_modules/@react-aria/landmark": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.9.tgz",
"integrity": "sha512-YYyluDBCXupnMh91ccE5g27fczjYmzPebHqTkVYjH4B6k45pOoqsMmWBCMnOTl0qOCeioI+daT8W0MamAZzoSw==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.4.tgz",
"integrity": "sha512-1U5ce6cqg1qGbK4M4R6vwrhUrKXuUzReZwHaTrXxEY22IMxKDXIZL8G7pFpcKix2XKqjLZWf+g8ngGuNhtQ2QQ==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/utils": "^3.33.0",
"@react-types/shared": "^3.33.0",
"@react-aria/utils": "^3.29.1",
"@react-types/shared": "^3.30.0",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
@@ -2782,9 +2457,9 @@
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
"version": "3.9.9",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.9.tgz",
"integrity": "sha512-2P5thfjfPy/np18e5wD4WPt8ydNXhij1jwA8oehxZTFqlgVMGXzcWKxTb4RtJrLFsqPO7RUQTiY8QJk0M4Vy2g==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -2904,18 +2579,18 @@
}
},
"node_modules/@react-aria/toast": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.10.tgz",
"integrity": "sha512-irW5Cr4msbPo4A4ysjT70MDJbpGCe1h9SkFgdYXBPA4Xbi4jRT7TiEZeIS1I7Hsvp6shAK1Ld/m6NBS0b/gyzg==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.5.tgz",
"integrity": "sha512-uhwiZqPy6hqucBUL7z6uUZjAJ/ou3bNdTjZlXS+zbcm+T0dsjKDfzNkaebyZY7AX3cYkFCaRjc3N6omXwoAviw==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/i18n": "^3.12.15",
"@react-aria/interactions": "^3.27.0",
"@react-aria/landmark": "^3.0.9",
"@react-aria/utils": "^3.33.0",
"@react-stately/toast": "^3.1.3",
"@react-types/button": "^3.15.0",
"@react-types/shared": "^3.33.0",
"@react-aria/i18n": "^3.12.10",
"@react-aria/interactions": "^3.25.3",
"@react-aria/landmark": "^3.0.4",
"@react-aria/utils": "^3.29.1",
"@react-stately/toast": "^3.1.1",
"@react-types/button": "^3.12.2",
"@react-types/shared": "^3.30.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
@@ -2997,15 +2672,15 @@
}
},
"node_modules/@react-aria/utils": {
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.0.tgz",
"integrity": "sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==",
"version": "3.29.1",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.29.1.tgz",
"integrity": "sha512-yXMFVJ73rbQ/yYE/49n5Uidjw7kh192WNN9PNQGV0Xoc7EJUlSOxqhnpHmYTyO0EotJ8fdM1fMH8durHjUSI8g==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-aria/ssr": "^3.9.9",
"@react-stately/flags": "^3.1.2",
"@react-stately/utils": "^3.11.0",
"@react-types/shared": "^3.33.0",
"@react-stately/utils": "^3.10.7",
"@react-types/shared": "^3.30.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
@@ -3437,13 +3112,13 @@
}
},
"node_modules/@react-stately/toast": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.3.tgz",
"integrity": "sha512-mT9QJKmD523lqFpOp0VWZ6QHZENFK7HrodnNJDVc7g616s5GNmemdlkITV43fSY3tHeThCVvPu+Uzh7RvQ9mpQ==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.1.tgz",
"integrity": "sha512-W4a6xcsFt/E+aHmR2eZK+/p7Y5rdyXSCQ5gKSnbck+S3lijEWAyV45Mv8v95CQqu0bQijj6sy2Js1szq10HVwg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -3495,9 +3170,9 @@
}
},
"node_modules/@react-stately/utils": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz",
"integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==",
"version": "3.10.7",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.7.tgz",
"integrity": "sha512-cWvjGAocvy4abO9zbr6PW6taHgF24Mwy/LbQ4TC4Aq3tKdKDntxyD+sh7AkSRfJRT2ccMVaHVv2+FfHThd3PKQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
@@ -3549,12 +3224,12 @@
}
},
"node_modules/@react-types/button": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.15.0.tgz",
"integrity": "sha512-X/K2/Oeuq7Hi8nMIzx4/YlZuvWFiSOHZt27p4HmThCnNO/9IDFPmvPrpkYjWN5eN9Nuk+P5vZUb4A7QJgYpvGA==",
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.12.2.tgz",
"integrity": "sha512-QLoSCX8E7NFIdkVMa65TPieve0rKeltfcIxiMtrphjfNn+83L0IHMcbhjf4r4W19c/zqGbw3E53Hx8mNukoTUw==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.33.0"
"@react-types/shared": "^3.30.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -3724,12 +3399,12 @@
}
},
"node_modules/@react-types/overlays": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.3.tgz",
"integrity": "sha512-LzetThNNk8T26pQRbs1I7+isuFhdFYREy7wJCsZmbB0FnZgCukGTfOtThZWv+ry11veyVJiX68jfl4SV6ACTWA==",
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
"license": "Apache-2.0",
"dependencies": {
"@react-types/shared": "^3.33.0"
"@react-types/shared": "^3.31.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -3785,9 +3460,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.0.tgz",
"integrity": "sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==",
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
@@ -4348,6 +4023,7 @@
"version": "22.16.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.0.tgz",
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -4386,13 +4062,6 @@
"resolved": "https://registry.npmjs.org/@types/symlink-or-copy/-/symlink-or-copy-1.2.2.tgz",
"integrity": "sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA=="
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.35.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
@@ -5420,16 +5089,15 @@
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/crisp-sdk-web": {
"version": "1.0.27",
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.0.27.tgz",
"integrity": "sha512-aNWR3te65YiaVFu/iwdqOo3cyUBZHUheE4d6EtgQu/T18jh/9SpoYXjXF/OzUD3Cqy0pGryoqtuy5gxD8tqX9Q==",
"license": "MIT"
"version": "1.0.25",
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.0.25.tgz",
"integrity": "sha512-CWTHFFeHRV0oqiXoPh/aIAKhFs6xcIM4NenGPnClAMCZUDQgQsF1OWmZWmnVNjJriXUmWRgDfeUxcxygS0dCRA=="
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -5708,15 +5376,6 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
@@ -6906,18 +6565,15 @@
}
},
"node_modules/humanize-duration": {
"version": "3.33.2",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz",
"integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==",
"license": "Unlicense",
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.0.tgz",
"integrity": "sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==",
"license": "Unlicense"
},
"node_modules/i18next": {
"version": "25.8.4",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.4.tgz",
"integrity": "sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==",
"version": "25.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
"integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==",
"funding": [
{
"type": "individual",
@@ -6934,7 +6590,7 @@
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"
@@ -7428,7 +7084,8 @@
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
"node_modules/javascript-stringify": {
"version": "2.1.0",
@@ -7905,12 +7562,6 @@
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/look-it-up": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/look-it-up/-/look-it-up-2.1.0.tgz",
@@ -8088,7 +7739,7 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "github",
@@ -8392,6 +8043,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -8417,7 +8069,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"devOptional": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -8464,7 +8116,7 @@
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -8609,31 +8261,33 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.342.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.342.1.tgz",
"integrity": "sha512-mMnQhWuKj4ejFicLtFzr52InmqploOyW1eInqXBkaVqE1DPhczBDmwsd9MSggY8kv0EXm8zgK+2tzBJUKcX5yg==",
"version": "1.256.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.256.2.tgz",
"integrity": "sha512-ypepnUHr33i5a1Uk39mozZXXTENRPC17HCG3WHKK6aRcpNwNs8uEqXaIKICGNM+qre+totKeTgl0WoaUFYmyoQ==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.208.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
"@opentelemetry/resources": "^2.2.0",
"@opentelemetry/sdk-logs": "^0.208.0",
"@posthog/core": "1.20.1",
"@posthog/types": "1.342.1",
"core-js": "^3.38.1",
"dompurify": "^3.3.1",
"fflate": "^0.4.8",
"preact": "^10.28.2",
"query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.1.0"
"preact": "^10.19.3",
"web-vitals": "^4.2.4"
},
"peerDependencies": {
"@rrweb/types": "2.0.0-alpha.17",
"rrweb-snapshot": "2.0.0-alpha.17"
},
"peerDependenciesMeta": {
"@rrweb/types": {
"optional": true
},
"rrweb-snapshot": {
"optional": true
}
}
},
"node_modules/preact": {
"version": "10.28.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
"license": "MIT",
"version": "10.24.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.0.tgz",
"integrity": "sha512-aK8Cf+jkfyuZ0ZZRG9FbYqwmEiGQ4y/PUO4SuTWoyWL244nZZh7bd5h2APd4rSNDYTBNghg1L+5iJN3Skxtbsw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -8649,9 +8303,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
@@ -8677,30 +8331,6 @@
"node": "10.* || >= 12.*"
}
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/proxy-compare": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz",
@@ -8716,12 +8346,6 @@
"node": ">=6"
}
},
"node_modules/query-selector-shadow-dom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
"integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -9123,6 +8747,17 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rrweb-snapshot": {
"version": "2.0.0-alpha.17",
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.17.tgz",
"integrity": "sha512-GBg5pV8LHOTbeVmH2VHLEFR0mc2QpQMzAvcoxEGfPNWgWHc8UvKCyq7pqN1vA+fDZ+yXXbixeO0kB2pzVvFCBw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/rsvp": {
"version": "4.8.5",
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
@@ -9280,6 +8915,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -9291,6 +8927,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -9356,7 +8993,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"devOptional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9903,6 +9540,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
@@ -9976,9 +9614,9 @@
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
"integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -10005,9 +9643,9 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/valtio": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.3.0.tgz",
"integrity": "sha512-1MfKNcmOIdBSatiJsYgw420n6jnD+jeoI0V+RkOQbCB0ElLh6GKUfPr0hc9uq/KBGeghivDEarRsKFFdSQQnKw==",
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
"license": "MIT",
"dependencies": {
"proxy-compare": "^3.0.1"
@@ -10293,9 +9931,9 @@
}
},
"node_modules/web-vitals": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
"license": "Apache-2.0"
},
"node_modules/webrtc-adapter": {
@@ -10333,6 +9971,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -10398,9 +10037,9 @@
}
},
"node_modules/wouter": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.9.0.tgz",
"integrity": "sha512-sF/od/PIgqEQBQcrN7a2x3MX6MQE6nW0ygCfy9hQuUkuB28wEZuu/6M5GyqkrrEu9M6jxdkgE12yDFsQMKos4Q==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.7.1.tgz",
"integrity": "sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==",
"license": "Unlicense",
"dependencies": {
"mitt": "^3.0.1",
+11 -11
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.7.0",
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,35 +13,35 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@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",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.9.3",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.27",
"crisp-sdk-web": "1.0.25",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "25.8.4",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.7",
"posthog-js": "1.342.1",
"posthog-js": "1.256.2",
"react": "18.3.1",
"react-aria-components": "1.10.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.3.0",
"wouter": "3.9.0"
"valtio": "2.1.5",
"wouter": "3.7.1"
},
"devDependencies": {
"@pandacss/dev": "0.54.0",
@@ -60,7 +60,7 @@
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.6",
"prettier": "3.8.1",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.0.8",
"vite-tsconfig-paths": "5.1.4"
@@ -1,6 +0,0 @@
[
{
"packageFamilyName" : "Visio_g3z6ba6vek6vg",
"paths" : [ "*" ]
}
]
-1
View File
@@ -17,7 +17,6 @@ export interface ApiConfig {
feedback: {
url: string
}
external_home_url?: string
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
custom_css_url?: string
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
<SlideContainer>
{SLIDES.map((slide, index) => (
<Slide visible={index == slideIndex} key={index}>
<Image src={slide.src} alt="" role="presentation" />
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
<TextAnimation visible={index == slideIndex}>
<Heading>{t(`${slide.key}.title`)}</Heading>
<Body>{t(`${slide.key}.body`)}</Body>
+1 -27
View File
@@ -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, useEffect, useState } from 'react'
import { ReactNode, useState } from 'react'
import { css } from '@/styled-system/css'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
@@ -19,7 +19,6 @@ 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 (
@@ -156,34 +155,9 @@ 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>
@@ -25,27 +25,15 @@ export const TermsOfServiceRoute = () => {
{/* Article 2 */}
<H lvl={2}>{t('articles.article2.title')}</H>
<P>{t('articles.article2.content')}</P>
{ensureArray(
t('articles.article2.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
<P>{t('articles.article2.purposes')}</P>
{/* Article 3 */}
<H lvl={2}>{t('articles.article3.title')}</H>
{ensureArray(
t('articles.article3.paragraphs', {
returnObjects: true,
})
).map((paragraph, index) => (
<P key={index}>{paragraph}</P>
))}
<P>{t('articles.article3.definition')}</P>
{/* Article 4 */}
<H lvl={2}>{t('articles.article4.title')}</H>
<P>{t('articles.article4.definition')}</P>
<P>{t('articles.article4.content')}</P>
{/* Article 5 */}
<H lvl={2} margin={false}>
@@ -74,7 +62,6 @@ export const TermsOfServiceRoute = () => {
__html: t('articles.article5.sections.section1.paragraph3'),
}}
></P>
<P>{t('articles.article5.sections.section1.paragraph4')}</P>
{/* Section 5.2 */}
<H lvl={3} bold>
@@ -125,6 +112,7 @@ export const TermsOfServiceRoute = () => {
{/* Article 7 */}
<H lvl={2}>{t('articles.article7.title')}</H>
<P>{t('articles.article7.content')}</P>
{/* Section 7.1 */}
<H lvl={3} bold>
@@ -1,7 +1,7 @@
import { css } from '@/styled-system/css'
import { css, cx } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { Spinner } from '@/primitives/Spinner'
import { Button, Icon, Text } from '@/primitives'
import { Button, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
import { ReactNode, useEffect, useRef, useState } from 'react'
@@ -141,23 +141,31 @@ export const ControlsButton = ({
})}
onPress={() => openSidePanel()}
>
<Icon
className={css({
color: 'primary.500',
marginRight: '1rem',
})}
name="info"
/>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginRight: '1rem',
})
)}
>
info
</span>
<Text variant={'smNote'}>
{parseLineBreaks(t('button.anotherModeStarted'))}
</Text>
<Icon
className={css({
color: 'primary.500',
marginLeft: 'auto',
})}
name="chevron_right"
/>
<span
className={cx(
'material-icons',
css({
color: 'primary.500',
marginLeft: 'auto',
})
)}
>
chevron_right
</span>
</RACButton>
)}
<Button
@@ -1,4 +1,4 @@
import { H, Text, Icon } from '@/primitives'
import { H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { LoginButton } from '@/components/LoginButton'
import { HStack } from '@/styled-system/jsx'
@@ -24,7 +24,9 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<Icon type="symbols" name="login" />
<span className="material-symbols" aria-hidden={true}>
login
</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useMemo, useRef, useEffect } from 'react'
import { useMemo, useRef, useState, useEffect } from 'react'
import { Text } from '@/primitives'
import {
RecordingMode,
@@ -13,7 +13,6 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useRoomMetadata } from '../hooks/useRoomMetadata'
import { RecordingStatusIcon } from './RecordingStatusIcon'
import { useIsRecording } from '@livekit/components-react'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
@@ -22,8 +21,8 @@ export const RecordingStateToast = () => {
const { openTranscript, openScreenRecording } = useSidePanel()
const [srMessage, setSrMessage] = useState('')
const lastKeyRef = useRef('')
const announce = useScreenReaderAnnounce()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
@@ -77,9 +76,16 @@ export const RecordingStateToast = () => {
if (key && key !== lastKeyRef.current) {
lastKeyRef.current = key
const message = t(key)
announce(message)
setSrMessage(message)
// Clear message after 3 seconds to prevent it from being announced again
const timer = setTimeout(() => {
setSrMessage('')
}, 3000)
return () => clearTimeout(timer)
}
}, [announce, key, t])
}, [key, t])
if (!key) return null
@@ -89,6 +95,15 @@ export const RecordingStateToast = () => {
return (
<>
{/* Screen reader only message to announce state changes once */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{srMessage}
</div>
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
<div
className={css({
@@ -1,5 +1,4 @@
import { Spinner } from '@/primitives/Spinner'
import { Icon } from '@/primitives'
interface RecordingStatusIconProps {
isStarted: boolean
@@ -15,8 +14,8 @@ export const RecordingStatusIcon = ({
}
if (isTranscriptActive) {
return <Icon type="symbols" name="speech_to_text" />
return <span className="material-symbols">speech_to_text</span>
}
return <Icon type="symbols" name="screen_record" />
return <span className="material-symbols">screen_record</span>
}
@@ -1,4 +1,4 @@
import { Button, Icon, H, Text } from '@/primitives'
import { Button, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
@@ -59,7 +59,7 @@ export const RequestRecording = ({
})}
>
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
<Icon type="symbols" name="person_raised_hand" />
<span className="material-symbols">person_raised_hand</span>
<H lvl={3} margin={false} padding={false}>
{heading}
</H>
@@ -1,6 +1,5 @@
import { css } from '@/styled-system/css'
import { ReactNode } from 'react'
import { Icon } from '@/primitives'
type RowPosition = 'first' | 'middle' | 'last' | 'single'
@@ -46,7 +45,7 @@ export const RowWrapper = ({
})}
>
{/* fixme - doesn't handle properly material-symbols */}
<Icon name={iconName} />
<span className="material-icons">{iconName}</span>
</div>
<div
className={css({
@@ -104,12 +104,14 @@ export const RecordingDownload = () => {
{t('success.title')}
</H>
<Text centered margin="md" wrap={'balance'}>
<span>
{t('success.body', {
room: data.room.name,
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
})}
</span>
<span
dangerouslySetInnerHTML={{
__html: t('success.body', {
room: data.room.name,
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
}),
}}
/>
<span>
{configData?.recording?.expiration_days && (
<>
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
square
size={'sm'}
onPress={copyRoomUrlToClipboard}
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
aria-label={t('copyUrl')}
tooltip={t('copyUrl')}
>
{isRoomUrlCopied ? (
<RiCheckLine aria-hidden="true" />
@@ -138,12 +138,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
{formatPinCode(roomData?.pin_code)}
</Text>
</div>
<Button
variant={isCopied ? 'success' : 'secondaryText'}
size="sm"
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
aria-label={t('copy')}
style={{
justifyContent: 'start',
}}
@@ -174,7 +173,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
<Button
variant={isCopied ? 'success' : 'tertiary'}
fullWidth
aria-label={isCopied ? t('copied') : t('copy')}
aria-label={t('copy')}
onPress={copyRoomToClipboard}
data-attr="share-dialog-copy"
>
@@ -446,14 +446,16 @@ export const Join = ({
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
aria-label={t('usernameLabel')}
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
labelProps={{
center: true,
}}
maxLength={50}
/>
</VStack>
@@ -1,29 +1,21 @@
import { A, Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
import { Button, Dialog, H, P } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { HStack } from '@/styled-system/jsx'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { navigateTo } from '@/navigation/navigateTo'
import humanizeDuration from 'humanize-duration'
import i18n from 'i18next'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
const FINAL_COUNTDOWN_SECONDS = 10
export const IsIdleDisconnectModal = () => {
const connectionObserverSnap = useSnapshot(connectionObserverStore)
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
const lastAnnouncementRef = useRef<number | null>(null)
const { openSettingsDialog } = useSettingsDialog()
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
const announce = useScreenReaderAnnounce()
useEffect(() => {
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
@@ -43,42 +35,10 @@ export const IsIdleDisconnectModal = () => {
}
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
useEffect(() => {
if (!connectionObserverSnap.isIdleDisconnectModalOpen) {
lastAnnouncementRef.current = null
}
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
const remainingSeconds = Math.floor(timeRemaining / 1000)
const minutes = Math.floor(remainingSeconds / 60)
const seconds = remainingSeconds % 60
const minutes = Math.floor(timeRemaining / 1000 / 60)
const seconds = (timeRemaining / 1000) % 60
const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
useEffect(() => {
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
const shouldAnnounce =
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
lastAnnouncementRef.current = remainingSeconds
const message = t('countdownAnnouncement', {
duration: humanizeDuration(remainingSeconds * 1000, {
language: i18n.language,
round: false,
largest: 2,
}),
})
announce(message, 'assertive', 'idle')
}
}, [
announce,
connectionObserverSnap.isIdleDisconnectModalOpen,
remainingSeconds,
t,
])
return (
<Dialog
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
@@ -92,7 +52,6 @@ export const IsIdleDisconnectModal = () => {
{({ close }) => {
return (
<div>
<ScreenReaderAnnouncer channel="idle" />
<div
className={css({
height: '50px',
@@ -106,7 +65,6 @@ export const IsIdleDisconnectModal = () => {
color: 'blue.800',
margin: 'auto',
})}
aria-hidden="true"
>
{formattedTime}
</div>
@@ -120,19 +78,7 @@ export const IsIdleDisconnectModal = () => {
}),
})}
</P>
<P>
{t('settingsPrefix')}{' '}
<A
color="primary"
onPress={() => {
connectionObserverStore.isIdleDisconnectModalOpen = false
openSettingsDialog(SettingsDialogExtendedKey.GENERAL)
}}
>
{t('settingsLink')}
</A>
{t('settingsSuffix')}
</P>
<P>{t('settings')}</P>
<HStack marginTop="2rem">
<Button
onPress={() => {
@@ -5,10 +5,6 @@ import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
@@ -144,41 +140,11 @@ export function ReactionPortal({
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const announce = useScreenReaderAnnounce()
const latestReaction =
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
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' })
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
return (
<>
{reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))}
</>
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))
@@ -13,7 +13,6 @@ 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
@@ -24,7 +23,6 @@ type StyledSidePanelProps = {
closeButtonTooltip: string
isSubmenu: boolean
onBack: () => void
backButtonLabel: string
}
const StyledSidePanel = ({
@@ -36,7 +34,6 @@ const StyledSidePanel = ({
closeButtonTooltip,
isSubmenu = false,
onBack,
backButtonLabel,
}: StyledSidePanelProps) => (
<aside
className={css({
@@ -66,34 +63,31 @@ const StyledSidePanel = ({
aria-hidden={isClosed}
aria-label={ariaLabel}
>
<HStack alignItems="center">
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{isSubmenu && (
<Button
variant="secondaryText"
size="sm"
square
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
aria-label={backButtonLabel}
className={css({ marginRight: '0.5rem' })}
onPress={onBack}
>
<RiArrowLeftLine size={20} aria-hidden="true" />
<RiArrowLeftLine size={20} />
</Button>
)}
<Heading
slot="title"
level={1}
className={text({ variant: 'h2' })}
style={{
paddingLeft: isSubmenu ? 0 : '1.5rem',
paddingTop: '1rem',
display: isClosed ? 'none' : 'flex',
justifyContent: 'start',
alignItems: 'center',
}}
>
{title}
</Heading>
</HStack>
{title}
</Heading>
<Div
position="absolute"
top="5"
@@ -135,6 +129,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
{keepAlive || isOpen ? children : null}
</div>
)
export const SidePanel = () => {
const {
activePanelId,
@@ -163,7 +158,6 @@ export const SidePanel = () => {
})}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)}
>
<Panel isOpen={isParticipantsOpen}>
@@ -1,4 +1,4 @@
import { A, Div, Icon, Text } from '@/primitives'
import { A, Div, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
@@ -87,7 +87,7 @@ const ToolButton = ({
alignItems: 'center',
})}
>
<Icon type="symbols" name="chevron_forward" />
<span className="material-symbols">chevron_forward</span>
</div>
</RACButton>
)
@@ -163,7 +163,7 @@ export const Tools = () => {
</Text>
{isTranscriptEnabled && (
<ToolButton
icon={<Icon type="symbols" name="speech_to_text" />}
icon={<span className="material-symbols">speech_to_text</span>}
title={t('tools.transcript.title')}
description={t('tools.transcript.body')}
onPress={() => openTranscript()}
@@ -171,7 +171,7 @@ export const Tools = () => {
)}
{isScreenRecordingEnabled && (
<ToolButton
icon={<Icon type="symbols" name="mode_standby" />}
icon={<span className="material-symbols">mode_standby</span>}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
@@ -11,7 +11,9 @@ import {
ProcessorType,
} from '.'
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
export class UnifiedBackgroundTrackProcessor
implements BackgroundProcessorInterface
{
processor: ProcessorWrapper<BackgroundOptions>
opts: BackgroundOptions
processorType: ProcessorType
@@ -13,7 +13,8 @@ export interface ProcessorSerialized {
options: BackgroundOptions
}
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
export interface BackgroundProcessorInterface
extends TrackProcessor<Track.Kind> {
update(opts: BackgroundOptions): Promise<void>
options: BackgroundOptions
clone(): BackgroundProcessorInterface
@@ -6,7 +6,6 @@ import { ToggleButton } from '@/primitives'
import { chatStore } from '@/stores/chat'
import { useSidePanel } from '../../hooks/useSidePanel'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ChatToggle = ({
onPress,
@@ -19,11 +18,6 @@ export const ChatToggle = ({
const { isChatOpen, toggleChat } = useSidePanel()
const tooltipLabel = isChatOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'toggle-chat',
handler: toggleChat,
})
return (
<div
className={css({
@@ -18,7 +18,6 @@ import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
type ToggleDeviceStyleProps = {
variant?: NonNullable<ButtonRecipeProps>['variant']
@@ -89,14 +88,12 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceShortcut = useDeviceShortcut(kind)
useRegisterKeyboardShortcut({
id: deviceShortcut?.id,
shortcut: deviceShortcut,
handler: async () => await toggle(),
isDisabled: cannotUseDevice,
})
const pushToTalkShortcut = getShortcutDescriptorById('push-to-talk')
useLongPress({
keyCode: kind === 'audioinput' ? pushToTalkShortcut?.code : undefined,
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
onKeyDown,
onKeyUp,
isDisabled: cannotUseDevice,
@@ -106,9 +103,7 @@ export const ToggleDevice = <T extends ToggleSource>({
const label = t(enabled ? 'disable' : 'enable', {
keyPrefix: `selectDevice.${kind}`,
})
return deviceShortcut?.shortcut
? appendShortcutLabel(label, deviceShortcut.shortcut)
: label
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
}, [enabled, kind, deviceShortcut, t])
const Icon =
@@ -9,7 +9,6 @@ import {
closeLowerHandToasts,
showLowerHandToast,
} from '@/features/notifications/utils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
const SPEAKING_DETECTION_DELAY = 3000
@@ -34,16 +33,6 @@ export const HandToggle = () => {
closeLowerHandToasts()
}, [isHandRaised])
const handleToggle = () => {
toggleRaisedHand()
resetToastState()
}
useRegisterKeyboardShortcut({
id: 'raise-hand',
handler: handleToggle,
})
useEffect(() => {
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
@@ -79,7 +68,10 @@ export const HandToggle = () => {
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isHandRaised}
onPress={handleToggle}
onPress={() => {
toggleRaisedHand()
resetToastState()
}}
data-attr={`controls-hand-${tooltipLabel}`}
>
<RiHand />
@@ -1,12 +1,10 @@
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'
import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
export const ParticipantsToggle = ({
onPress,
@@ -21,18 +19,11 @@ export const ParticipantsToggle = ({
*/
const participants = useParticipants()
const numParticipants = participants?.length
const announcedCount =
numParticipants && numParticipants > 0 ? numParticipants : 1
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
useRegisterKeyboardShortcut({
id: 'toggle-participants',
handler: toggleParticipants,
})
return (
<div
className={css({
@@ -40,24 +31,21 @@ export const ParticipantsToggle = ({
display: 'inline-block',
})}
>
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
<ToggleButton
square
variant="primaryTextDark"
aria-label={`${t(tooltipLabel)}. ${t('count', {
count: announcedCount,
})}.`}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
</VisualOnlyTooltip>
<ToggleButton
square
variant="primaryTextDark"
aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)}
isSelected={isParticipantsOpen}
onPress={(e) => {
toggleParticipants()
onPress?.(e)
}}
data-attr={`controls-participants-${tooltipLabel}`}
{...props}
>
<RiGroupLine />
</ToggleButton>
<div
className={css({
position: 'absolute',
@@ -10,8 +10,6 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
@@ -42,11 +40,6 @@ export const ReactionsToggle = () => {
const [isVisible, setIsVisible] = useState(false)
useRegisterKeyboardShortcut({
id: 'reaction',
handler: () => setIsVisible((prev) => !prev),
})
const sendReaction = async (emoji: string) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
@@ -152,7 +145,7 @@ export const ReactionsToggle = () => {
<Button
key={index}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
aria-label={t('send', { emoji })}
variant="primaryTextDark"
size="sm"
square
@@ -6,7 +6,8 @@ import {
import React from 'react'
/** @public */
export interface AllowMediaPlaybackProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export interface AllowMediaPlaybackProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
label?: string
}
@@ -18,7 +18,6 @@ import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
enum BlurRadius {
NONE = 0,
@@ -57,11 +56,11 @@ export const EffectsConfiguration = ({
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
const announce = useScreenReaderAnnounce()
const effectAnnouncementTimeout = useRef<ReturnType<
typeof setTimeout
> | null>(null)
const effectAnnouncementId = useRef(0)
const [blurStatusMessage, setBlurStatusMessage] = useState('')
const blurAnnouncementTimeout = useRef<ReturnType<typeof setTimeout> | null>(
null
)
const blurAnnouncementId = useRef(0)
useEffect(() => {
const videoElement = videoRef.current
@@ -90,24 +89,27 @@ export const EffectsConfiguration = ({
useEffect(
() => () => {
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
if (blurAnnouncementTimeout.current) {
clearTimeout(blurAnnouncementTimeout.current)
}
},
[]
)
const announceEffectStatusMessage = (message: string) => {
effectAnnouncementId.current += 1
const currentId = effectAnnouncementId.current
const announceBlurStatusMessage = (message: string) => {
blurAnnouncementId.current += 1
const currentId = blurAnnouncementId.current
if (effectAnnouncementTimeout.current) {
clearTimeout(effectAnnouncementTimeout.current)
if (blurAnnouncementTimeout.current) {
clearTimeout(blurAnnouncementTimeout.current)
}
effectAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== effectAnnouncementId.current) return
announce(message)
// Clear the region first so screen readers drop queued announcements.
setBlurStatusMessage('')
blurAnnouncementTimeout.current = setTimeout(() => {
if (currentId !== blurAnnouncementId.current) return
setBlurStatusMessage(message)
}, 80)
}
@@ -116,42 +118,25 @@ export const EffectsConfiguration = ({
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 = (
const updateBlurStatusMessage = (
type: ProcessorType,
options: BackgroundOptions,
wasSelectedBeforeToggle: boolean
) => {
if (type !== ProcessorType.BLUR) return
let message = ''
if (wasSelectedBeforeToggle) {
announceEffectStatusMessage(t('blur.status.none'))
return
message = t('blur.status.none')
} else if (options.blurRadius === BlurRadius.LIGHT) {
message = t('blur.status.light')
} else if (options.blurRadius === BlurRadius.NORMAL) {
message = t('blur.status.strong')
}
if (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
}
if (message) {
announceBlurStatusMessage(message)
}
}
@@ -215,7 +200,7 @@ export const EffectsConfiguration = ({
onSubmit?.(processor)
}
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
updateBlurStatusMessage(type, options, wasSelectedBeforeToggle)
} catch (error) {
console.error('Error applying effect:', error)
} finally {
@@ -421,6 +406,9 @@ export const EffectsConfiguration = ({
<BlurOnStrong />
</ToggleButton>
</div>
<div aria-live="polite" className="sr-only">
{blurStatusMessage}
</div>
</div>
<div
className={css({
@@ -11,7 +11,8 @@ const ASPECT_RATIO = 16 / 10
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
/** @public */
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
export interface CarouselLayoutProps
extends React.HTMLAttributes<HTMLMediaElement> {
tracks: TrackReferenceOrPlaceholder[]
children: React.ReactNode
/** Place the tiles vertically or horizontally next to each other.
@@ -13,8 +13,7 @@ import { PaginationControl } from '../controls/PaginationControl'
/** @public */
export interface GridLayoutProps
extends
React.HTMLAttributes<HTMLDivElement>,
extends React.HTMLAttributes<HTMLDivElement>,
Pick<UseParticipantsOptions, 'updateOnlyOn'> {
children: React.ReactNode
tracks: TrackReferenceOrPlaceholder[]
@@ -12,6 +12,6 @@ export function useCanPublishTrack(trackSource: TrackSource): boolean {
return Boolean(
permissions?.canPublish &&
permissions?.canPublishSources?.includes(trackSource)
permissions?.canPublishSources?.includes(trackSource)
)
}
@@ -1,16 +1,19 @@
import { useMemo } from 'react'
import {
getShortcutDescriptorById,
ShortcutDescriptor,
} from '@/features/shortcuts/catalog'
import { Shortcut } from '@/features/shortcuts/types'
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
return useMemo<ShortcutDescriptor | undefined>(() => {
return useMemo<Shortcut | undefined>(() => {
switch (kind) {
case 'audioinput':
return getShortcutDescriptorById('toggle-microphone')
return {
key: 'd',
ctrlKey: true,
}
case 'videoinput':
return getShortcutDescriptorById('toggle-camera')
return {
key: 'e',
ctrlKey: true,
}
default:
return undefined
}
@@ -18,7 +18,8 @@ import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
extends React.HTMLAttributes<HTMLDivElement>,
ChatOptions {}
/**
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
@@ -22,7 +22,7 @@ export function DesktopControlBar({
const desktopControlBarEl = useRef<HTMLDivElement>(null)
useRegisterKeyboardShortcut({
id: 'focus-toolbar',
shortcut: { key: 'F2' },
handler: () => {
const root = desktopControlBarEl.current
if (!root) return
@@ -5,8 +5,9 @@ import {
isWeb,
log,
} from '@livekit/components-core'
import { Participant, RoomEvent, Track } from 'livekit-client'
import React, { useCallback, useRef, useState, useEffect } from 'react'
import { RoomEvent, Track } from 'livekit-client'
import * as React from 'react'
import { useState } from 'react'
import {
ConnectionStateToast,
FocusLayoutContainer,
@@ -15,9 +16,7 @@ import {
usePinnedTracks,
useTracks,
useCreateLayoutContext,
useRoomContext,
} from '@livekit/components-react'
import { useTranslation } from 'react-i18next'
import { ControlBar } from './ControlBar/ControlBar'
import { styled } from '@/styled-system/jsx'
@@ -38,8 +37,6 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { CarouselLayout } from '../components/layout/CarouselLayout'
import { GridLayout } from '../components/layout/GridLayout'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
const LayoutWrapper = styled(
'div',
@@ -66,7 +63,8 @@ const LayoutWrapper = styled(
/**
* @public
*/
export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
export interface VideoConferenceProps
extends React.HTMLAttributes<HTMLDivElement> {
/** @alpha */
SettingsComponent?: React.ComponentType
}
@@ -91,22 +89,7 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
*/
export function VideoConference({ ...props }: VideoConferenceProps) {
const lastAutoFocusedScreenShareTrack =
useRef<TrackReferenceOrPlaceholder | null>(null)
const lastPinnedParticipantIdentityRef = useRef<string | null>(null)
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
const { t: tRooms } = useTranslation('rooms')
const room = useRoomContext()
const announce = useScreenReaderAnnounce()
const getAnnouncementName = useCallback(
(participant?: Participant | null) => {
if (!participant) return tRooms('participants.unknown')
return participant.isLocal
? tRooms('participants.you')
: getParticipantName(participant)
},
[tRooms]
)
React.useRef<TrackReferenceOrPlaceholder | null>(null)
useConnectionObserver()
useVideoResolutionSubscription()
@@ -132,62 +115,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
(track) => !isEqualTrackRef(track, focusTrack)
)
// handle pin announcements
useEffect(() => {
const participant = focusTrack?.participant
// 1. unpin
if (!participant) {
if (!lastPinnedParticipantIdentityRef.current) return
const lastIdentity = lastPinnedParticipantIdentityRef.current
const lastParticipant =
room.localParticipant.identity === lastIdentity
? room.localParticipant
: room.remoteParticipants.get(lastIdentity)
const announcementName = getAnnouncementName(lastParticipant)
announce(
lastParticipant?.isLocal
? t('self.unpin')
: t('unpin', {
name: announcementName,
})
)
lastPinnedParticipantIdentityRef.current = null
return
}
// 2. same pin → do nothing
if (lastPinnedParticipantIdentityRef.current === participant.identity) {
return
}
// 3. new pin
const participantName = participant.isLocal
? tRooms('participants.you')
: getParticipantName(participant)
lastPinnedParticipantIdentityRef.current = participant.identity
announce(
participant.isLocal ? t('self.pin') : t('pin', { name: participantName })
)
}, [
announce,
focusTrack,
getAnnouncementName,
room.localParticipant,
room.remoteParticipants,
t,
tRooms,
])
/* eslint-disable react-hooks/exhaustive-deps */
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
useEffect(() => {
React.useEffect(() => {
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
if (
screenShareTracks.some((track) => track.publication.isSubscribed) &&
@@ -9,7 +9,8 @@ import NoiseSuppressorWorklet from '@timephy/rnnoise-wasm/NoiseSuppressorWorklet
// and suspend/resume it as needed to manage audio state
let audioContext: AudioContext
export interface AudioProcessorInterface extends TrackProcessor<Track.Kind.Audio> {
export interface AudioProcessorInterface
extends TrackProcessor<Track.Kind.Audio> {
name: string
}
@@ -1,24 +0,0 @@
import { useTranslation } from 'react-i18next'
export const getEmojiLabel = (
emoji: string,
t: ReturnType<typeof useTranslation>['t']
) => {
const emojiLabels: Record<string, string> = {
'thumbs-up': t('emojis.thumbs-up', { defaultValue: 'thumbs up' }),
'thumbs-down': t('emojis.thumbs-down', { defaultValue: 'thumbs down' }),
'clapping-hands': t('emojis.clapping-hands', {
defaultValue: 'clapping hands',
}),
'red-heart': t('emojis.red-heart', { defaultValue: 'red heart' }),
'face-with-tears-of-joy': t('emojis.face-with-tears-of-joy', {
defaultValue: 'face with tears of joy',
}),
'face-with-open-mouth': t('emojis.face-with-open-mouth', {
defaultValue: 'surprised face',
}),
'party-popper': t('emojis.party-popper', { defaultValue: 'party popper' }),
'folded-hands': t('emojis.folded-hands', { defaultValue: 'folded hands' }),
}
return emojiLabels[emoji] ?? emoji
}
@@ -22,11 +22,6 @@ const Heading = styled('h1', {
},
})
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
}
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
const [, setLocation] = useLocation()
@@ -37,25 +32,21 @@ export const FeedbackRoute = () => {
if (!state?.reason) return
switch (state.reason) {
case DisconnectReason.DUPLICATE_IDENTITY:
return DisconnectReasonKey.DuplicateIdentity
return 'duplicateIdentity'
case DisconnectReason.PARTICIPANT_REMOVED:
return DisconnectReasonKey.ParticipantRemoved
return 'participantRemoved'
}
}, [])
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
return (
<Screen layout="centered" footer={false}>
<Center>
<VStack>
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<HStack>
{showBackButton && (
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
)}
<Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')}
</Button>
<Button variant="primary" onPress={() => setLocation('/')}>
{t('feedback.home')}
</Button>
@@ -2,7 +2,6 @@ import { Dialog, type DialogProps } from '@/primitives'
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx'
import { Icon } from '@/primitives/Icon'
import { Heading } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import {
@@ -11,7 +10,6 @@ import {
RiSettings3Line,
RiSpeakerLine,
RiVideoOnLine,
RiEyeLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
@@ -23,7 +21,6 @@ import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import AccessibilityTab from './tabs/AccessibilityTab'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -109,16 +106,11 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon type="symbols" name="speech_to_text" />
<span className="material-symbols">speech_to_text</span>
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
</Tab>
</TabList>
</div>
<div className={tabPanelContainerStyle}>
@@ -132,7 +124,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
</div>
</Tabs>
</Dialog>
@@ -1,40 +0,0 @@
import { Field, H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { accessibilityStore } from '@/stores/accessibility'
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
const { t } = useTranslation('settings')
const snap = useSnapshot(accessibilityStore)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('tabs.accessibility')}</H>
<ul
className={css({
display: 'flex',
flexDirection: 'column',
gap: '1rem',
})}
>
<li>
<Field
type="switch"
label={t('accessibility.announceReactions.label')}
isSelected={snap.announceReactions}
onChange={(value) => {
accessibilityStore.announceReactions = value
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</li>
</ul>
</TabPanel>
)
}
export default AccessibilityTab
@@ -5,5 +5,4 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
ACCESSIBILITY = 'accessibility',
}
@@ -1,83 +0,0 @@
import { Shortcut } from './types'
// Central list of current keyboard shortcuts.
// Keep a single source of truth for display and, later, customization
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
| 'focus-toolbar'
| 'toggle-microphone'
| 'toggle-camera'
| 'push-to-talk'
| 'toggle-chat'
| 'toggle-participants'
| 'raise-hand'
| 'recording'
| 'reaction'
| 'fullscreen'
export const getShortcutDescriptorById = (id: ShortcutId) =>
shortcutCatalog.find((item) => item.id === id)
export type ShortcutDescriptor = {
id: ShortcutId
category: ShortcutCategory
shortcut?: Shortcut
kind?: 'press' | 'longPress'
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
description?: string
}
export const shortcutCatalog: ShortcutDescriptor[] = [
{
id: 'focus-toolbar',
category: 'navigation',
shortcut: { key: 'F2' },
},
{
id: 'toggle-microphone',
category: 'media',
shortcut: { key: 'd', ctrlKey: true },
},
{
id: 'toggle-camera',
category: 'media',
shortcut: { key: 'e', ctrlKey: true },
},
{
id: 'push-to-talk',
category: 'media',
kind: 'longPress',
code: 'KeyV',
},
{
id: 'reaction',
category: 'interaction',
shortcut: { key: 'E', ctrlKey: true, shiftKey: true },
},
{
id: 'fullscreen',
category: 'interaction',
shortcut: { key: 'F', ctrlKey: true, shiftKey: true },
},
{
id: 'recording',
category: 'interaction',
shortcut: { key: 'L', ctrlKey: true, shiftKey: true },
},
{
id: 'raise-hand',
category: 'interaction',
shortcut: { key: 'H', ctrlKey: true, shiftKey: true },
},
{
id: 'toggle-chat',
category: 'interaction',
shortcut: { key: 'M', ctrlKey: true, shiftKey: true },
},
{
id: 'toggle-participants',
category: 'interaction',
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
},
]
@@ -1,6 +1,4 @@
export type Shortcut = {
key: string
ctrlKey?: boolean
shiftKey?: boolean
altKey?: boolean
}
@@ -11,13 +11,11 @@ export const useKeyboardShortcuts = () => {
// This approach handles basic shortcuts but isn't comprehensive.
// Issues might occur. First draft.
const onKeyDown = async (e: KeyboardEvent) => {
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
const { key, metaKey, ctrlKey } = e
if (!key) return
const shortcutKey = formatShortcutKey({
key,
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
shiftKey,
altKey,
})
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
if (!shortcut) return
@@ -1,28 +1,26 @@
import { useEffect } from 'react'
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
import { formatShortcutKey } from '@/features/shortcuts/utils'
import { ShortcutId, getShortcutDescriptorById } from './catalog'
import { Shortcut } from '@/features/shortcuts/types'
export type useRegisterKeyboardShortcutProps = {
id?: ShortcutId
shortcut?: Shortcut
handler: () => Promise<void | boolean | undefined> | void
isDisabled?: boolean
}
export const useRegisterKeyboardShortcut = ({
id,
shortcut,
handler,
isDisabled = false,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!id) return
const descriptor = getShortcutDescriptorById(id)
if (!descriptor?.shortcut) return
const formattedKey = formatShortcutKey(descriptor.shortcut)
if (!shortcut) return
const formattedKey = formatShortcutKey(shortcut)
if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
} else {
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
}
}, [handler, id, isDisabled])
}, [handler, shortcut, isDisabled])
}
+4 -16
View File
@@ -4,27 +4,15 @@ import { Shortcut } from '@/features/shortcuts/types'
export const CTRL = 'ctrl'
export const formatShortcutKey = (shortcut: Shortcut) => {
const parts = []
if (shortcut.ctrlKey) parts.push(CTRL)
if (shortcut.altKey) parts.push('alt')
if (shortcut.shiftKey) parts.push('shift')
parts.push(shortcut.key.toUpperCase())
return parts.join('+')
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
return shortcut.key.toUpperCase()
}
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
if (!shortcut.key) return
const parts: string[] = []
let formattedKeyLabel = shortcut.key.toLowerCase()
if (shortcut.ctrlKey) {
parts.push(isMacintosh() ? '⌘' : 'Ctrl')
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
}
if (shortcut.altKey) {
parts.push(isMacintosh() ? '⌥' : 'Alt')
}
if (shortcut.shiftKey) {
parts.push('Shift')
}
parts.push(shortcut.key.toLowerCase())
const formattedKeyLabel = parts.join('+')
return `${label} (${formattedKeyLabel})`
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { useSubtitles } from '../hooks/useSubtitles'
import { css, cva } from '@/styled-system/css'
import { styled } from '@/styled-system/jsx'
@@ -13,62 +13,101 @@ export interface TranscriptionSegment {
id: string
text: string
language: string
startTime?: number
startTime: number
endTime: number
final: boolean
firstReceivedTime: number
lastReceivedTime: number
}
export interface TranscriptionSegmentWithParticipant extends TranscriptionSegment {
participant: Participant
}
export interface TranscriptionRow {
id: string
participant: Participant
segments: TranscriptionSegment[]
startTime?: number
startTime: number
lastUpdateTime: number
lastReceivedTime: number
}
const useTranscriptionState = () => {
const [transcriptionSegments, setTranscriptionSegments] = useState<
TranscriptionSegmentWithParticipant[]
const [transcriptionRows, setTranscriptionRows] = useState<
TranscriptionRow[]
>([])
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
useState<string | null>(null)
const updateTranscriptionSegments = (
const updateTranscriptions = (
segments: TranscriptionSegment[],
participant?: Participant
) => {
console.log(participant, segments)
if (!participant || segments.length === 0) return
if (segments.length > 1) {
console.warn('Unexpected error more segments')
return
}
setTranscriptionRows((prevRows) => {
const updatedRows = [...prevRows]
const now = Date.now()
const segment = segments[0]
const shouldAppendToLastRow =
lastActiveParticipantIdentity === participant.identity &&
updatedRows.length > 0
setTranscriptionSegments((prevSegments) => {
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
if (existingSegmentIds.has(segment.id)) return prevSegments
return [
...prevSegments,
{
participant: participant,
...segment,
},
]
if (shouldAppendToLastRow) {
const lastRowIndex = updatedRows.length - 1
const lastRow = updatedRows[lastRowIndex]
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
const newSegments = segments.filter(
(segment) => !existingSegmentIds.has(segment.id)
)
const updatedSegments = lastRow.segments.map((existing) => {
const update = segments.find((s) => s.id === existing.id)
return update && update.final ? update : existing
})
updatedRows[lastRowIndex] = {
...lastRow,
segments: [...updatedSegments, ...newSegments],
lastUpdateTime: now,
}
} else {
const newRow: TranscriptionRow = {
id: `${participant.identity}-${now}`,
participant,
segments: [...segments],
startTime: Math.min(...segments.map((s) => s.startTime)),
lastUpdateTime: now,
}
updatedRows.push(newRow)
}
return updatedRows
})
setLastActiveParticipantIdentity(participant.identity)
}
const clearTranscriptions = () => {
setTranscriptionRows([])
setLastActiveParticipantIdentity(null)
}
const updateParticipant = (_name: string, participant: Participant) => {
setTranscriptionRows((prevRows) => {
return prevRows.map((row) => {
if (row.participant.identity === participant.identity) {
return {
...row,
participant,
}
}
return row
})
})
}
return {
updateTranscriptionSegments,
transcriptionSegments,
transcriptionRows,
updateTranscriptions,
clearTranscriptions,
updateParticipant,
}
}
@@ -153,54 +192,24 @@ const SubtitlesWrapper = styled(
export const Subtitles = () => {
const { areSubtitlesOpen } = useSubtitles()
const room = useRoomContext()
const { transcriptionSegments, updateTranscriptionSegments } =
const { transcriptionRows, updateTranscriptions, updateParticipant } =
useTranscriptionState()
useEffect(() => {
if (!room) return
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
return () => {
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
}
}, [room, updateTranscriptionSegments])
}, [room, updateTranscriptions])
const transcriptionRows = useMemo(() => {
if (transcriptionSegments.length === 0) return []
const rows: TranscriptionRow[] = []
let currentRow: TranscriptionRow | null = null
for (const segment of transcriptionSegments) {
const shouldStartNewRow =
!currentRow ||
currentRow.participant.identity !== segment.participant.identity
if (shouldStartNewRow) {
currentRow = {
id: `${segment.participant.identity}-${segment.firstReceivedTime}`,
participant: segment.participant,
segments: [segment],
startTime: segment.startTime,
lastUpdateTime: segment.lastReceivedTime,
lastReceivedTime: segment.lastReceivedTime,
}
rows.push(currentRow)
} else if (currentRow) {
currentRow.segments.push(segment)
currentRow.lastUpdateTime = Math.max(
currentRow.lastUpdateTime,
segment.lastReceivedTime
)
currentRow.lastReceivedTime = Math.max(
currentRow.lastReceivedTime,
segment.lastReceivedTime
)
}
useEffect(() => {
if (!room) return
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
return () => {
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
}
return rows
}, [transcriptionSegments])
}, [room, updateParticipant])
return (
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
@@ -1,19 +0,0 @@
import { useCallback } from 'react'
import {
announceToScreenReader,
type Politeness,
type ScreenReaderChannel,
} from '@/stores/screenReaderAnnouncer'
export const useScreenReaderAnnounce = () => {
return useCallback(
(
message: string,
politeness: Politeness = 'polite',
channel: ScreenReaderChannel = 'global'
) => {
announceToScreenReader(message, politeness, channel)
},
[]
)
}
+16 -23
View File
@@ -10,7 +10,6 @@ import { FeedbackBanner } from '@/components/FeedbackBanner'
import { Menu } from '@/primitives/Menu'
import { MenuList } from '@/primitives/MenuList'
import { LoginButton } from '@/components/LoginButton'
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
import { useLoginHint } from '@/hooks/useLoginHint'
@@ -91,11 +90,6 @@ export const Header = () => {
const isTermsOfService = useMatchesRoute('termsOfService')
const isRoom = useMatchesRoute('room')
const { user, isLoggedIn, logout } = useUser()
const userLabel = user?.full_name || user?.email
const loggedInTooltip = t('loggedInUserTooltip')
const loggedInAriaLabel = userLabel
? `${loggedInTooltip} ${userLabel}`
: loggedInTooltip
return (
<>
@@ -159,24 +153,23 @@ export const Header = () => {
)}
{!!user && (
<Menu>
<Button size="sm" variant="secondaryText">
<VisualOnlyTooltip
tooltip={loggedInTooltip}
ariaLabel={loggedInAriaLabel}
tooltipPosition="bottom"
<Button
size="sm"
variant="secondaryText"
tooltip={t('loggedInUserTooltip')}
tooltipType="delayed"
>
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '350px',
display: { base: 'none', xsm: 'block' },
})}
>
<span
className={css({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '350px',
display: { base: 'none', xsm: 'block' },
})}
>
{user?.full_name || user?.email}
</span>
</VisualOnlyTooltip>
{user?.full_name || user?.email}
</span>
</Button>
<MenuList
variant={'light'}
-2
View File
@@ -4,7 +4,6 @@ import { Header } from './Header'
import { layoutStore } from '@/stores/layout'
import { useSnapshot } from 'valtio'
import { Footer } from '@/layout/Footer'
import { ScreenReaderAnnouncer } from '@/primitives'
export type Layout = 'fullpage' | 'centered'
@@ -42,7 +41,6 @@ export const Layout = ({ children }: { children: ReactNode }) => {
flexDirection: 'column',
})}
>
<ScreenReaderAnnouncer />
{children}
</main>
</div>
+6 -3
View File
@@ -44,15 +44,18 @@
},
"slide1": {
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!",
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere.",
"imgAlt": "Illustration einer benutzerfreundlichen und barrierefreien Kollaborationsplattform"
},
"slide2": {
"title": "Gruppenanrufe ohne Einschränkungen",
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße."
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße.",
"imgAlt": "Bild eines virtuellen Meetings mit mehreren nahtlos zusammenarbeitenden Teilnehmenden"
},
"slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!"
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!",
"imgAlt": "Illustration KI-gestützter Notizen in einem virtuellen Meeting"
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
},
"success": {
"title": "Ihre Aufzeichnung ist bereit!",
"body": "Aufzeichnung des Treffens {{room}} vom {{created_at}}.",
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
"button": "Herunterladen",
"warning": {
+4 -31
View File
@@ -86,7 +86,6 @@
"heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"closeDialog": "Dialogfenster schließen",
"phone": {
"call": "Rufen Sie an:",
"pinCode": "Code:"
@@ -154,12 +153,9 @@
"isIdleDisconnectModal": {
"title": "Bist du noch da?",
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
"settingsPrefix": "Um diese Nachricht nicht mehr zu sehen, gehe zu",
"settingsLink": "Einstellungen > Allgemein",
"settingsSuffix": ".",
"settings": "Um diese Nachricht nicht mehr zu sehen, gehe zu Einstellungen > Allgemein.",
"stayButton": "Gespräch fortsetzen",
"leaveButton": "Jetzt verlassen",
"countdownAnnouncement": "Das Gespräch endet in {{duration}}."
"leaveButton": "Jetzt verlassen"
},
"controls": {
"microphone": "Mikrofon",
@@ -196,9 +192,7 @@
"leave": "Verlassen",
"participants": {
"open": "Alle ausblenden",
"closed": "Alle anzeigen",
"count_one": "{{count}} Teilnehmer",
"count_other": "{{count}} Teilnehmer"
"closed": "Alle anzeigen"
},
"tools": {
"open": "Weitere Tools ausblenden",
@@ -212,18 +206,7 @@
"reactions": {
"button": "Reaktion senden",
"send": "Reaktion {{emoji}} senden",
"announce": "{{name}} : {{emoji}}",
"you": "Sie",
"emojis": {
"thumbs-up": "Daumen hoch",
"thumbs-down": "Daumen runter",
"clapping-hands": "Klatschende Hände",
"red-heart": "rotes Herz",
"face-with-tears-of-joy": "Gesicht mit Freudentränen",
"face-with-open-mouth": "überraschter Gesichtsausdruck",
"party-popper": "Party-Popper",
"folded-hands": "gefaltete Hände"
}
"you": "Sie"
}
},
"options": {
@@ -294,7 +277,6 @@
},
"sidePanel": {
"ariaLabel": "Seitenleiste",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": {
"participants": "Teilnehmer",
"effects": "Effekte",
@@ -507,7 +489,6 @@
"participants": {
"subheading": "Im Raum",
"you": "Du",
"unknown": "Unbekannter Teilnehmer",
"host": "Host",
"contributors": "Mitwirkende",
"collapsable": {
@@ -555,14 +536,6 @@
"ariaLabel": "Hefte {{name}} an"
}
},
"pinAnnouncements": {
"pin": "Das Video von {{name}} ist angeheftet.",
"unpin": "Das Video von {{name}} ist nicht mehr angeheftet.",
"self": {
"pin": "Dein Video ist angeheftet.",
"unpin": "Dein Video ist nicht mehr angeheftet."
}
},
"recordingStateToast": {
"transcript": {
"started": "Transkription läuft",
@@ -108,18 +108,12 @@
"label": "Sprache"
},
"settingsButtonLabel": "Einstellungen",
"accessibility": {
"announceReactions": {
"label": "Reaktionen vorlesen"
}
},
"tabs": {
"account": "Profil",
"audio": "Audio",
"video": "Video",
"general": "Allgemein",
"notifications": "Benachrichtigungen",
"accessibility": "Barrierefreiheit",
"transcription": "Transkription"
}
}
+53 -65
View File
@@ -3,127 +3,115 @@
"articles": {
"article1": {
"title": "1. Geltungsbereich",
"content": "Dieses Dokument legt die Nutzungsbedingungen für Visio (nachfolgend „Visio“ oder „die Anwendung“) fest, eine Videokonferenzanwendung, die speziell für staatliche Verwaltungen und ihnen unterstellte Einrichtungen entwickelt wurde."
"content": "Dieses Dokument definiert die Nutzungsbedingungen für Visio, den Videokonferenzdienst der Verwaltung."
},
"article2": {
"title": "2. Zweck der Anwendung",
"content": "Visio fördert die Zusammenarbeit der Teams staatlicher Verwaltungen und ihnen unterstellter Einrichtungen, unterstützt die Arbeit auf Distanz und erleichtert die Organisation von Besprechungen, Konferenzen oder Schulungen.",
"paragraphs": [
"Visio wird von der Interministeriellen Direktion für Digitales (DINUM) entwickelt und betrieben.",
"Die Anwendung wird von der DINUM staatlichen Verwaltungen und ihnen unterstellten Einrichtungen für deren Mitarbeitende, Partner und eingeladene Dienstleister zur Verfügung gestellt.",
"Jede Nutzung von Visio muss diesen Nutzungsbedingungen sowie gegebenenfalls den Nutzungsvorgaben der zuständigen Verwaltung entsprechen."
]
"title": "2. Zweck der Plattform",
"content": "Der Dienst fördert die Zusammenarbeit im Team und das Arbeiten aus der Ferne und erleichtert die Organisation von Besprechungen, Konferenzen oder Schulungen.",
"purposes": "Visio wird von DINUM entwickelt und betrieben. Jede Nutzung des Dienstes muss mit diesen Nutzungsbedingungen übereinstimmen."
},
"article3": {
"title": "3. Begriffsbestimmungen",
"paragraphs": [
"Visio ist das Produkt, dessen Nutzung in diesen Bedingungen beschrieben wird und das auf der folgenden Seite vorgestellt ist: https://visio.numerique.gouv.fr.",
"Nutzerinnen und Nutzer von Visio sind alle Personen, die Visio zu Zwecken der Videokommunikation verwenden, unabhängig davon, ob sie Mitarbeitende, Partner oder Dienstleister der zuständigen Verwaltungen sind. Die zuständige Verwaltung ist die staatliche Verwaltung oder ihr unterstellte Einrichtung, die Visio ihren Mitarbeitenden, Partnern oder Dienstleistern zur Verfügung stellt.",
"Raumorganisatorinnen und -organisatoren sind Nutzerinnen und Nutzer, die nach Anmeldung über ProConnect für einen Videokonferenzraum verantwortlich sind."
]
"title": "3. Zugang zum Dienst",
"definition": "Visio wird öffentlichen Bediensteten zur Verfügung gestellt, die über ProConnect verbunden sind, um einen Videokonferenzraum zu verwalten. Der verbundene öffentliche Bedienstete kann dann einen Link übermitteln, der den Zugang zum Raum unter seiner Verantwortung ermöglicht."
},
"article4": {
"title": "4. Zugang zur Anwendung",
"definition": "Visio wird den Nutzerinnen und Nutzern von ihrer zuständigen Verwaltung zur Verfügung gestellt. Organisatorinnen und Organisatoren können anschließend unter eigener Verantwortung einen Zugangslink zum Raum an beliebige Personen weitergeben."
"title": "4. Nutzung",
"content": "Die Nutzung des Dienstes ist kostenlos. Um einen Videokonferenzraum zu verwalten, ist eine Verbindung über ProConnect erforderlich."
},
"article5": {
"title": "5. Funktionen",
"sections": {
"section1": {
"title": "5.1. Funktionen für Organisatorinnen und Organisatoren",
"content": "Visio ermöglicht Mitarbeitenden staatlicher Verwaltungen und ihnen unterstellter Einrichtungen, von einem Standardarbeitsplatz aus einen Videokonferenzraum zu erstellen, der mit ihrem ProConnect-Konto verknüpft ist.",
"paragraph1": "Jede Mitarbeiterin und jeder Mitarbeiter des Staates oder einer ihm unterstellten Einrichtung, deren Tätigkeiten mit der Nutzung von Visio vereinbar sind oder die keinen besonderen Vertraulichkeits- oder Berufsgeheimnispflichten unterliegen, kann eine Videokonferenz erstellen und organisieren. Der Einladungslink kann anschließend mit anderen Personen, Nutzerinnen und Nutzern, innerhalb oder außerhalb der Verwaltung geteilt werden.",
"paragraph2": "Zu diesem Zweck kann jede Raumorganisatorin bzw. jeder Raumorganisator:",
"title": "5.1. Funktionen für öffentliche Bedienstete",
"content": "Visio bietet öffentlichen Bediensteten die Möglichkeit, von einem Standardarbeitsplatz aus einen Videokonferenzraum zu erstellen, der mit ihrem ProConnect-Konto verknüpft ist.",
"paragraph1": "Jeder Staatsbedienstete, dessen Tätigkeiten mit der Nutzung von Visio vereinbar sind oder der keinen spezifischen Vertraulichkeitsverpflichtungen oder dem Berufsgeheimnis unterliegt, kann eine Videokonferenz erstellen. Er kann dann den Besprechungslink mit anderen Teilnehmern teilen, unabhängig davon, ob sie Teil der Verwaltung sind oder nicht.",
"paragraph2": "Zu diesem Zweck kann jeder Raumorganisator:",
"capabilities": [
"den Zugang zum Raum durch einen Warteraum schützen;",
"den Zutritt jeder Nutzerin und jedes Nutzers zum Raum zulassen;",
"Mikrofone und Kameras der Nutzerinnen und Nutzer stummschalten;",
"eine Nutzerin oder einen Nutzer aus dem Raum entfernen;",
"das Transkriptionstool der Sitzung nach Einholung der Zustimmung der Nutzerinnen und Nutzer unter eigener Verantwortung verwenden."
"Den Zugang zum Raum mit einem Warteraum schützen;",
"Den Eintritt jedes Teilnehmers in den Raum autorisieren;",
"Die Mikrofone und Kameras der Teilnehmer stummschalten;",
"Einen Teilnehmer aus dem Raum entfernen."
],
"paragraph3": "Nur Mitarbeitende, deren ProConnect-Konto zu einer zur Raumorganisation in Visio berechtigten Domäne gehört oder die keiner besonderen Vertraulichkeits- oder Berufsgeheimnispflicht unterliegen, dürfen die Anwendung und ihre Funktionen nutzen oder kontrollieren.",
"paragraph4": "Das automatische Transkriptionstool kann von Organisatorinnen und Organisatoren über das Einstellungsmenü des Raums genutzt werden. Es liegt in ihrer Verantwortung, die Zustimmung der an der transkribierten Sitzung teilnehmenden Nutzerinnen und Nutzer sicherzustellen. Die Transkriptionssprache kann im selben Menü geändert werden. Am Ende der Sitzung erhält die Organisatorin bzw. der Organisator einen Link zum Zugriff auf die im von der DINUM betriebenen Tool „Docs“ gespeicherte Transkription."
"paragraph3": "Nur Bedienstete, deren Tätigkeitsbereich zur Nutzung von Visio berechtigt ist oder die <u>keinen spezifischen Vertraulichkeitsverpflichtungen oder dem Berufsgeheimnis unterliegen</u>, dürfen diesen Dienst und seine Funktionen nutzen oder kontrollieren."
},
"section2": {
"title": "5.2. Funktionen für Nutzerinnen und Nutzer",
"content": "Visio ermöglicht es jeder Nutzerin und jedem Nutzer, unabhängig davon, ob sie oder er dem öffentlichen Dienst angehört oder nicht, an jedem Visio-Raum teilzunehmen, zu dem sie oder er von einer Organisatorin oder einem Organisator eingeladen wurde, und die Funktionen zu nutzen.",
"paragraph": "Zu diesem Zweck kann jede Nutzerin und jeder Nutzer:",
"title": "5.2. Funktionen für Nutzer",
"content": "Visio ermöglicht es jedem Nutzer, ob öffentlicher Bediensteter oder nicht, an jedem Visio-Raum teilzunehmen, zu dem er von einem Staatsbediensteten eingeladen wurde, und alle Funktionen zu nutzen.",
"paragraph": "Zu diesem Zweck kann jeder Teilnehmer:",
"capabilities": [
"beim Betreten des Raums einen Anzeigenamen angeben;",
"per Audio und/oder Video aus der Ferne kommunizieren;",
"den eigenen Bildschirm teilen;",
"während der Sitzung öffentliche Nachrichten mit allen Nutzerinnen und Nutzern des Raums austauschen;",
"auf technische Informationen zum Raum und Barrierefreiheitsfunktionen zugreifen;",
"Kamera und/oder Mikrofon jederzeit ein- oder ausschalten;",
"die Privatsphäre der eigenen Umgebung bei aktivierter Kamera schützen, indem vor oder während der Videokonferenz ein Hintergrund ausgewählt und aktiviert wird."
"Seine angezeigte Identität beim Betreten des Raums festlegen;",
"Über Audio und/oder Video aus der Ferne kommunizieren;",
"Seinen Bildschirm teilen;",
"Während der Sitzung öffentlich mit allen Nachrichten austauschen;",
"Informationen über die Konferenz einsehen;",
"Seine Kamera und/oder sein Mikrofon jederzeit ein- oder ausschalten;",
"Die Privatsphäre seines Arbeitsplatzes schützen, indem er vor dem Betreten des Raums oder sogar während der Videokonferenz einen Hintergrund auswählt und aktiviert."
]
}
}
},
"article6": {
"title": "6. Verpflichtungen und Verantwortlichkeiten der Nutzerinnen und Nutzer",
"title": "6. Verpflichtungen und Verantwortlichkeiten der Nutzer",
"sections": {
"section1": {
"title": "6.1 Zulässige Nutzung",
"title": "6.1. Konforme Nutzung",
"paragraphs": [
"Visio wird bereitgestellt, um den beruflichen Austausch zwischen Mitarbeitenden staatlicher Verwaltungen und ihnen unterstellter Einrichtungen sowie externen Mitarbeitenden zu erleichtern.",
"Nutzerinnen und Nutzer sind verantwortlich für die Bilder und Töne, die sie im Videokonferenzraum zugänglich machen. Diese Verantwortung gilt sowohl für das, was Kamera oder Mikrofon übertragen, als auch für das, was ihr Endgerät Dritten zugänglich machen kann. Daher ist die Anwendung in einem professionellen Umfeld zu nutzen, und es dürfen keine Informationen, die der beruflichen Verschwiegenheitspflicht unterliegen, an Dritte weitergegeben werden (z. B. durch Nutzung von Sichtschutzfiltern, Kopfhörern usw.).",
"Nutzerinnen und Nutzer sind für die Daten oder Inhalte verantwortlich, die sie in den Nachrichtenbereich des Raums eingeben. Es dürfen nur angemessene Nachrichten eingegeben werden."
"Der Dienst wird öffentlichen Bediensteten über ProConnect zur Verfügung gestellt.",
"Der Nutzer ist für die Daten oder Inhalte verantwortlich, die er im Nachrichtendienst des Videokonferenzraums eingibt. Er muss sicherstellen, dass er nur angemessene Nachrichten eingibt."
]
},
"section2": {
"title": "6.2 Unzulässige Nutzung",
"title": "6.2. Verbotene Nutzung",
"paragraphs": [
"Nutzerinnen und Nutzer verpflichten sich, im Nachrichtenbereich von Visio oder über Bild oder Ton keine Inhalte oder Informationen zugänglich zu machen, die gegen geltende gesetzliche oder regulatorische Bestimmungen verstoßen."
"Der Nutzer verpflichtet sich, keine Inhalte oder Informationen in den Nachrichtendienst des Visio-Raums einzugeben, die gegen geltende gesetzliche und regulatorische Bestimmungen verstoßen."
]
}
}
},
"article7": {
"title": "7. Verpflichtungen und Verantwortlichkeiten der DINUM",
"title": "7. Verpflichtungen und Verantwortlichkeiten von DINUM",
"content": "DINUM ist für die allgemeine Verwaltung des Dienstes verantwortlich. Sie stellt den Bediensteten alle erforderlichen Informationen zur Verfügung, um die Nutzung zu erleichtern. Sie bietet technische und funktionale Unterstützung, um den ordnungsgemäßen Betrieb des Dienstes sicherzustellen. Sie informiert die Nutzer auf jede zumutbare Weise über jede Schwierigkeit, die diesen ordnungsgemäßen Betrieb beeinträchtigen könnte.",
"sections": {
"section1": {
"title": "7.1. Sicherheit der Anwendung",
"title": "7.1. Verfügbarkeitsniveau",
"paragraphs": [
"DINUM verpflichtet sich zur Absicherung von Visio, insbesondere durch alle notwendigen Maßnahmen zur Gewährleistung der Sicherheit und Vertraulichkeit der Kommunikation.",
"Die Anwendung ist gemäß den Bedingungen des geänderten Dekrets Nr. 2010-112 zugelassen."
"Der Dienst ist 24/7 verfügbar, vorbehaltlich Wartungszeiten.",
"DINUM verfolgt ein jährliches Verfügbarkeitsziel von 99,5 % (ohne geplante Ausfälle). Bei einem Vorfall oder einer Wartung strebt sie eine Wiederherstellungszeit von 72 Stunden außerhalb der Geschäftszeiten an.",
"Im Rahmen der Wartung der Ausführungsumgebung des Dienstes behält sich DINUM das Recht vor, den Betrieb des Dienstes vorübergehend auszusetzen. Sie informiert die Nutzer mindestens 48 Stunden im Voraus. In dringenden Fällen kann diese Aussetzung ohne Vorankündigung erfolgen.",
"Diese außergewöhnlichen Stillstände können beispielsweise für Maßnahmen zur Datenverwaltung im Back Office, Produktionsbereitstellungen oder Architekturänderungen erforderlich sein.",
"Eine Nichtverfügbarkeit des Dienstes begründet keinerlei Anspruch auf Entschädigung."
]
},
"section2": {
"title": "7.2. Zugang zur Anwendung",
"title": "7.2. Sicherheit des Dienstes",
"paragraphs": [
"DINUM verpflichtet sich, die notwendigen und angemessenen Mittel bereitzustellen, um einen kontinuierlichen Zugang zur Anwendung sicherzustellen.",
"Die Anwendung ist 24 Stunden am Tag, 7 Tage die Woche verfügbar, außer während Wartungszeiten.",
"DINUM strebt eine jährliche Verfügbarkeit der Anwendung von 99,5 % an, ausgenommen geplante Ausfälle. Im Falle eines Vorfalls oder einer Wartung wird eine Wiederherstellungszeit von 72 Stunden außerhalb der Geschäftszeiten angestrebt.",
"Im Rahmen von Wartungsarbeiten an der Laufzeitumgebung der Anwendung behält sich DINUM das Recht vor, den Betrieb vorübergehend auszusetzen. Soweit möglich, werden die Nutzerinnen und Nutzer mindestens 48 Stunden im Voraus informiert. In Notfällen kann die Aussetzung ohne Vorankündigung erfolgen.",
"Solche außergewöhnlichen Ausfälle können beispielsweise für Datenverwaltungsmaßnahmen, Produktivsetzungen oder Architekturänderungen erforderlich sein.",
"Eine Nichtverfügbarkeit der Anwendung begründet keinen Anspruch auf irgendeine Form von Entschädigung."
"DINUM ergreift alle erforderlichen Vorsichtsmaßnahmen, um die Sicherheit der Plattform und der im Rahmen des Dienstes eingesetzten Werkzeuge zu gewährleisten, insbesondere in Bezug auf den Zugang zum Dienst, die Verwaltung der Nutzerkonten und die Verarbeitung der erhobenen Daten."
]
},
"section3": {
"title": "7.3. Support",
"title": "7.3. Support-Management",
"paragraphs": [
"DINUM bietet den Nutzerinnen und Nutzern Support der ersten Ebene, ausschließlich zu technischen Aspekten der Anwendung.",
"Dieser Support ist über das Online-Kontaktformular erreichbar.",
"Die angestrebte Antwortzeit beträgt zwei Arbeitstage ab Versand der Anfrage; dieser Zeitraum kann je nach Komplexität und Anzahl der Anfragen variieren."
"DINUM leistet First-Level-Support für die Nutzer, ausschließlich zu den technischen Teilen des Dienstes.",
"Dieser Support ist über unser Online-Kontaktformular erreichbar.",
"Die angestrebte Antwortzeit auf Anfragen beträgt zwei Werktage ab Absenden; dieser Zeitraum kann je nach Komplexität und Anzahl der zu bearbeitenden Anfragen variieren."
]
},
"section4": {
"title": "7.4. Kontrolle der Nutzung der Anwendung",
"title": "7.4. Kontrolle der Dienstenutzung",
"paragraphs": [
"DINUM behält sich das Recht vor, ein Nutzerkonto zu sperren oder zu löschen, wenn gegen diese Nutzungsbedingungen verstoßen wurde, unbeschadet möglicher straf- oder zivilrechtlicher Schritte gegen die betreffende Person. Informationen können an die zuständige Verwaltung übermittelt werden, die für die Bereitstellung der Anwendung verantwortlich ist.",
"Die Sperrung oder Löschung eines oder mehrerer Konten begründet keinen Anspruch auf Entschädigung irgendeiner Art."
"DINUM behält sich das Recht vor, ein Agentenkonto zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger straf- und zivilrechtlicher Maßnahmen gegen den betreffenden Agenten.",
"Die Sperrung oder Aufhebung eines oder mehrerer Konten begründet keinen Anspruch auf irgendeine Entschädigung."
]
},
"section5": {
"title": "7.5. Open Source und Lizenzen",
"content": "Der Quellcode des Dienstes ist offen und hier verfügbar: https://github.com/suitenumerique/meet. Von DINUM bereitgestellte Inhalte stehen unter der Open License 2.0, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die eigenen Lizenzen unterliegen können."
"content": "Der Quellcode des Dienstes ist frei und hier verfügbar: https://github.com/suitenumerique/meet. Die von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
}
}
},
"article8": {
"title": "8. Änderungen der Nutzungsbedingungen",
"content": "Die vorliegenden Nutzungsbedingungen können jederzeit und ohne Vorankündigung geändert oder ergänzt werden, insbesondere aufgrund von Änderungen am Dienst, gesetzlicher Entwicklungen oder aus anderen als notwendig erachteten Gründen.\nDiese Änderungen und Aktualisierungen sind für die Nutzerinnen und Nutzer verbindlich; sie sollten daher regelmäßig diesen Abschnitt konsultieren, um die geltenden Bedingungen zu überprüfen."
"title": "8. Änderung der Nutzungsbedingungen",
"content": "Die Bedingungen dieser Nutzungsbedingungen können jederzeit ohne Vorankündigung geändert oder ergänzt werden, abhängig von Änderungen am Dienst, Gesetzesänderungen oder aus anderen als notwendig erachteten Gründen.\nDiese Änderungen und Aktualisierungen sind für den Nutzer verbindlich, der daher regelmäßig diesen Abschnitt konsultieren muss, um die geltenden Bedingungen zu überprüfen."
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
"heading": "You don't have the permission to view this page"
},
"loading": "Loading…",
"loggedInUserTooltip": "Logged in as ",
"loggedInUserTooltip": "Logged in as",
"login": {
"buttonLabel": "Login",
"proconnectButtonLabel": "Login with ProConnect",
+6 -3
View File
@@ -44,15 +44,18 @@
},
"slide1": {
"title": "Make the switch to simplicity. Try us now!",
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more."
"body": "Discover an intuitive and accessible solution, designed for all public agents, their partners, and much more.",
"imgAlt": "Illustration of a user-friendly and accessible collaboration platform"
},
"slide2": {
"title": "Host group calls without limits",
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size."
"body": "Unlimited time meetings, with smooth and high-quality communication, no matter the group size.",
"imgAlt": "Image of a virtual meeting with multiple participants collaborating seamlessly"
},
"slide3": {
"title": "Transform your meetings with AI",
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!"
"body": "Get accurate and actionable transcripts to boost your productivity. Feature in beta—try it now!",
"imgAlt": "Illustration of AI-powered note-taking in a virtual meeting"
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
},
"success": {
"title": "Your recording is ready!",
"body": "Recording of the meeting {{room}} from {{created_at}}.",
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
"button": "Download",
"warning": {

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