mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2861aa143e | |||
| 4748b55089 | |||
| ce9f812a7e | |||
| b02591170f | |||
| e58181f846 | |||
| d37f47e82c | |||
| db80c09c10 | |||
| fd9f2a81ca | |||
| d865db5f1b | |||
| 7cc5b2b961 | |||
| c85977cb68 | |||
| 3c3b4a32e3 | |||
| 9b033c55b2 | |||
| a2c7becaf4 | |||
| 89031abb63 | |||
| fc92fa4eb4 | |||
| 2c65cc061e | |||
| bfadeae6ee | |||
| 117677bd14 | |||
| 69c6e58017 | |||
| 6742f5d19d | |||
| 23de7e52bc | |||
| 3887255e9c | |||
| 5d6ad3f3f6 | |||
| 44d68a9c80 | |||
| ed5c1bbd84 | |||
| f8c6da8021 | |||
| 5ba1657e00 | |||
| c28b8ba902 | |||
| 6962367e18 | |||
| 0bd57e8623 | |||
| 27f2023104 | |||
| 44362eca23 | |||
| c34a85699b | |||
| 12d8c4a9db | |||
| 42a05da5c0 | |||
| 4344dd6e35 | |||
| fe28902b2e | |||
| 1e1e1a2657 | |||
| f4e48dafac | |||
| 9f58efb851 | |||
| 716e11b5b3 | |||
| 88a1136dfd | |||
| 90633928a8 | |||
| fd894eb61f | |||
| bb64532cff | |||
| 692c55ed1b | |||
| df616ae711 | |||
| 021d7a7e06 | |||
| f2a3e7c8de | |||
| cf07ceb67e | |||
| ea7fb5fc27 | |||
| 6e8a6ce82a | |||
| ce960ae330 | |||
| f9dd2e1909 | |||
| 9023e54352 | |||
| 8295574616 | |||
| db15c8b6cc | |||
| e1aeec6053 | |||
| c5aa762e11 | |||
| 8f710a4626 | |||
| 60d1338eff | |||
| f8436d9ae2 | |||
| 39fb273201 | |||
| d101459115 | |||
| 88696a23fd | |||
| 13d26a76b3 | |||
| b675517a60 | |||
| a5254ffd59 | |||
| ff82bca9ec | |||
| 99a18b6e90 | |||
| 250e599465 | |||
| 144a4e1b85 | |||
| 78ab3cdbdf | |||
| a815d6c00d | |||
| dfbc3a9d17 | |||
| 086db3d089 | |||
| 014ef3d804 | |||
| de3e1a56a8 | |||
| 459749b992 | |||
| e1450329f2 | |||
| c7e3194331 | |||
| 902b005f32 | |||
| 51d22783b2 | |||
| 76f80a0f2f | |||
| 82eb930200 | |||
| eeeb950e08 | |||
| cb77688572 | |||
| f9524b2f0a | |||
| a50aabeaf8 | |||
| 594bd5a692 | |||
| 69d92e6f30 | |||
| c47e830b40 | |||
| d7f1b7b94c | |||
| 8072d2c950 | |||
| 726f9097f9 | |||
| bbc7fa8012 | |||
| 41db3e766b | |||
| 1ab3ce6d47 | |||
| 3cd5c77f42 | |||
| 3ddb075c6b | |||
| 9ed2500565 | |||
| 1001783d3c | |||
| 97b5e8780c | |||
| 7c7074aa99 | |||
| 35b3bcad63 | |||
| 137a2c7f6f | |||
| d681e25bcc | |||
| 1f1a6371b4 | |||
| bbfbb23be5 | |||
| 6e20bc1f43 | |||
| fed05f2396 | |||
| 865acf2838 | |||
| 6ae68013af | |||
| 394a1be322 | |||
| a71a1fd968 | |||
| 40af264562 | |||
| 8b2d06976e | |||
| 58313666ed | |||
| f3c8aec189 | |||
| 0a0c7ba618 | |||
| d7ad5aed05 | |||
| 4acc9cf40d | |||
| 13d0d3d801 | |||
| 47cd3eff74 | |||
| 5769203705 | |||
| cadc186c62 | |||
| 5be7595533 |
@@ -147,6 +147,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||||
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||||
|
docker-context: './src/summary'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -178,6 +186,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||||
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||||
|
docker-context: './src/agents'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
|||||||
+104
-3
@@ -8,19 +8,120 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) Use React Aria FocusScope for side panel accessibility
|
||||||
|
|
||||||
|
## [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 reduced‑motion 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
|
||||||
|
|
||||||
|
- ✨(summary) add dutch and german languages
|
||||||
|
- 🔧(agents) make Silero VAD optional
|
||||||
|
- 🚸(frontend) explain to a user they were ejected
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 📈(frontend) track new recording's modes
|
||||||
|
- ♿️(frontend) improve accessibility of the background and effects menu
|
||||||
|
- ♿️(frontend) improve SR and focus for transcript and recording #810
|
||||||
|
- 💄(frontend) adjust spacing in the recording side panels
|
||||||
|
- 🚸(frontend) remove the default comma delimiter in humanized durations
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) remove unexpected F2 tooltip when clicking video screen
|
||||||
|
- 🩹(frontend) icon font loading to avoid text/icon flickering
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-01-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
- ✨(agent) support Kyutai client for subtitle
|
- ✨(agent) support Kyutai client for subtitle
|
||||||
- ✨(frontend) remove the beta badge
|
|
||||||
- ✨(all) support starting transcription and recording simultaneously
|
- ✨(all) support starting transcription and recording simultaneously
|
||||||
- ✨(backend) persist options on a recording
|
- ✨(backend) persist options on a recording
|
||||||
- ✨(all) support choosing the transcription language
|
- ✨(all) support choosing the transcription language
|
||||||
- ✨(summary) add a download link to the audio/video file
|
- ✨(summary) add a download link to the audio/video file
|
||||||
- ✨(frontend) allow unprivileged users to request a recording
|
- ✨(frontend) allow unprivileged users to request a recording
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 🚸(frontend) remove the beta badge
|
||||||
- ♻️(summary) extract file handling in a robust service
|
- ♻️(summary) extract file handling in a robust service
|
||||||
- ♻️(all) manage recording state on the backend side
|
- ♻️(all) manage recording state on the backend side
|
||||||
|
|
||||||
## [1.1.0] - 2025-12-22
|
## [1.1.0] - 2025-12-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(backend) enable user creation via email for external integrations
|
- ✨(backend) enable user creation via email for external integrations
|
||||||
- ✨(summary) add Langfuse observability for LLM API calls
|
- ✨(summary) add Langfuse observability for LLM API calls
|
||||||
@@ -32,6 +133,6 @@ and this project adheres to
|
|||||||
- ♿(frontend) improve accessibility:
|
- ♿(frontend) improve accessibility:
|
||||||
- ♿️(frontend) hover controls, focus, SR #803
|
- ♿️(frontend) hover controls, focus, SR #803
|
||||||
- ♿️(frontend) change ptt keybinding from space to v #813
|
- ♿️(frontend) change ptt keybinding from space to v #813
|
||||||
- ♿(frontend) indicate external link opens in new window on feedback #816
|
- ♿(frontend) indicate external link opens in new window on feedback #816
|
||||||
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
|
- ♿(frontend) fix heading level in modal to maintain semantic hierarchy #815
|
||||||
- ♿️(frontend) Improve focus management when opening and closing chat #807
|
- ♿️(frontend) Improve focus management when opening and closing chat #807
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
FROM python:3.13.5-alpine3.21 AS base
|
FROM python:3.13.5-alpine3.21 AS base
|
||||||
|
|
||||||
# Upgrade pip to its latest release to speed up dependencies installation
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip setuptools
|
RUN python -m pip install --upgrade pip
|
||||||
|
|
||||||
# Upgrade system packages to install security updates
|
# Upgrade system packages to install security updates
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
web: bin/buildpack_start.sh
|
||||||
|
postdeploy: python manage.py migrate
|
||||||
@@ -50,6 +50,9 @@ La Suite Meet is fully self-hostable and released under the MIT License, ensurin
|
|||||||
|
|
||||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
We’re 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, France’s Minister for Civil Service and State Reform, announced the full deployment of Visio—the French government’s 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
|
## Table of Contents
|
||||||
|
|
||||||
@@ -86,7 +89,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.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.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 |
|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||||
| [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. |
|
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/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
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/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
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/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 $$
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# 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 {
|
upstream meet_backend {
|
||||||
server ${BACKEND_HOST}:8000 fail_timeout=0;
|
server ${BACKEND_INTERNAL_HOST}:8000 fail_timeout=0;
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream meet_frontend {
|
upstream meet_frontend {
|
||||||
server ${FRONTEND_HOST}:8080 fail_timeout=0;
|
server ${FRONTEND_INTERNAL_HOST}:8080 fail_timeout=0;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
|
|||||||
@@ -3,3 +3,8 @@ redis:
|
|||||||
address: redis:6379
|
address: redis:6379
|
||||||
keys:
|
keys:
|
||||||
devkey: secret
|
devkey: secret
|
||||||
|
|
||||||
|
webhook:
|
||||||
|
api_key: devkey
|
||||||
|
urls:
|
||||||
|
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ services:
|
|||||||
- env.d/postgresql
|
- env.d/postgresql
|
||||||
- env.d/common
|
- env.d/common
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/databases/backend:/var/lib/postgresql/data/pgdata
|
- ./data/databases/backend:/var/lib/postgresql/data
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:5
|
image: redis:5
|
||||||
@@ -20,8 +20,8 @@ services:
|
|||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
restart: always
|
restart: always
|
||||||
env_file:
|
env_file:
|
||||||
|
- .env
|
||||||
- env.d/common
|
- env.d/common
|
||||||
- env.d/backend
|
|
||||||
- env.d/postgresql
|
- env.d/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "manage.py", "check"]
|
test: ["CMD", "python", "manage.py", "check"]
|
||||||
@@ -45,6 +45,7 @@ services:
|
|||||||
- /docker-entrypoint.sh
|
- /docker-entrypoint.sh
|
||||||
command: ["nginx", "-g", "daemon off;"]
|
command: ["nginx", "-g", "daemon off;"]
|
||||||
env_file:
|
env_file:
|
||||||
|
- .env
|
||||||
- env.d/common
|
- env.d/common
|
||||||
# Uncomment and set your values if using our nginx proxy example
|
# Uncomment and set your values if using our nginx proxy example
|
||||||
# environment:
|
# environment:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
### Step 1: Prepare your working environment:
|
### Step 1: Prepare your working environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir keycloak/env.d && cd keycloak
|
mkdir -p 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 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/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
|
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
|
#### Step 3: Get Client Credentials
|
||||||
|
|
||||||
1. Go to the "Credentials" tab.
|
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.
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ 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 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.
|
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
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Installation with docker compose
|
# Installation with docker compose
|
||||||
|
|
||||||
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/k8s.md)
|
We provide a sample configuration for running Meet using Docker Compose. Please note that this configuration is experimental, and the official way to deploy Meet in production is to use [k8s](../installation/kubernetes.md).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ curl -o default.conf.template https://raw.githubusercontent.com/suitenumerique/m
|
|||||||
|
|
||||||
## Step 2: Configuration
|
## Step 2: Configuration
|
||||||
|
|
||||||
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../env.md).
|
Meet configuration is achieved through environment variables. We provide a [detailed description of all variables](../../src/helm/meet/README.md).
|
||||||
|
|
||||||
In this example, we assume the following services:
|
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
|
- 7881/tcp - WebRTC ICE over TCP
|
||||||
- 7882/udp - for WebRTC multiplexing over UDP
|
- 7882/udp - for WebRTC multiplexing over UDP
|
||||||
|
|
||||||
If you are using ufw, enter the follwoing:
|
If you are using ufw, enter the following:
|
||||||
```
|
```
|
||||||
ufw allow 80/tcp
|
ufw allow 80/tcp
|
||||||
ufw allow 443/tcp
|
ufw allow 443/tcp
|
||||||
@@ -177,6 +177,15 @@ You will need to uncomment the environment and network sections in compose file
|
|||||||
# external: true
|
# external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Caddy Reverse Proxy
|
||||||
|
Expose the Frontend port to the host
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
…
|
||||||
|
ports:
|
||||||
|
- "8086:8086"
|
||||||
|
```
|
||||||
|
|
||||||
## Step 5: Start Meet
|
## Step 5: Start Meet
|
||||||
|
|
||||||
You are ready to start your Meet application !
|
You are ready to start your Meet application !
|
||||||
@@ -198,7 +207,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.
|
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
|
## How to upgrade your Meet application
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# 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,7 +1,7 @@
|
|||||||
APP_NAME="meet-app-summary-dev"
|
APP_NAME="meet-app-summary-dev"
|
||||||
APP_API_TOKEN="password"
|
APP_API_TOKEN="password"
|
||||||
|
|
||||||
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
||||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||||
AWS_S3_SECURE_ACCESS=false
|
AWS_S3_SECURE_ACCESS=false
|
||||||
|
|
||||||
|
|||||||
@@ -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_USE_SSL=true # A flag to enable or disable SSL for email sending.
|
||||||
|
|
||||||
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
|
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
|
# Backend url
|
||||||
MEET_BASE_URL="https://${MEET_HOST}"
|
MEET_BASE_URL="https://${MEET_HOST}"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ logger = logging.getLogger("transcriber")
|
|||||||
|
|
||||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||||
|
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
def create_stt_provider():
|
def create_stt_provider():
|
||||||
@@ -122,9 +123,8 @@ class MultiUserTranscriber:
|
|||||||
if participant.identity in self._sessions:
|
if participant.identity in self._sessions:
|
||||||
return self._sessions[participant.identity]
|
return self._sessions[participant.identity]
|
||||||
|
|
||||||
session = AgentSession(
|
vad = self.ctx.proc.userdata.get("vad", None)
|
||||||
vad=self.ctx.proc.userdata["vad"],
|
session = AgentSession(vad=vad)
|
||||||
)
|
|
||||||
room_io = RoomIO(
|
room_io = RoomIO(
|
||||||
agent_session=session,
|
agent_session=session,
|
||||||
room=self.ctx.room,
|
room=self.ctx.room,
|
||||||
@@ -193,7 +193,8 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
|||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
def prewarm(proc: JobProcess):
|
||||||
"""Preload voice activity detection model."""
|
"""Preload voice activity detection model."""
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
if ENABLE_SILERO_VAD:
|
||||||
|
proc.userdata["vad"] = silero.VAD.load()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.1.0"
|
version = "1.7.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.3.10",
|
"livekit-agents==1.3.10",
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ class RoomAdmin(admin.ModelAdmin):
|
|||||||
list_filter = ["access_level", "created_at"]
|
list_filter = ["access_level", "created_at"]
|
||||||
readonly_fields = ["id", "created_at", "updated_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):
|
def get_owner(self, obj):
|
||||||
"""Return the owner of the room for display in the admin list."""
|
"""Return the owner of the room for display in the admin list."""
|
||||||
|
|
||||||
@@ -138,6 +142,7 @@ class RecordingAccessInline(admin.TabularInline):
|
|||||||
|
|
||||||
model = models.RecordingAccess
|
model = models.RecordingAccess
|
||||||
extra = 0
|
extra = 0
|
||||||
|
autocomplete_fields = ["user"]
|
||||||
|
|
||||||
|
|
||||||
@admin.action(description=_("Resend notification to external service"))
|
@admin.action(description=_("Resend notification to external service"))
|
||||||
@@ -207,8 +212,18 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
"created_at",
|
"created_at",
|
||||||
"worker_id",
|
"worker_id",
|
||||||
)
|
)
|
||||||
list_filter = ["status", "room", "created_at"]
|
list_filter = ["created_at"]
|
||||||
readonly_fields = ["id", "created_at", "updated_at"]
|
list_select_related = ("room",)
|
||||||
|
readonly_fields = (
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
"options",
|
||||||
|
"mode",
|
||||||
|
"room",
|
||||||
|
"status",
|
||||||
|
"updated_at",
|
||||||
|
"worker_id",
|
||||||
|
)
|
||||||
actions = [resend_notification]
|
actions = [resend_notification]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""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"
|
||||||
@@ -10,7 +10,7 @@ from django.http import Http404
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from rest_framework import decorators, mixins, pagination, throttling, viewsets
|
from rest_framework import decorators, mixins, pagination, viewsets
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
)
|
)
|
||||||
@@ -58,7 +58,7 @@ from core.services.room_creation import RoomCreation
|
|||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
from . import permissions, serializers
|
from . import permissions, serializers, throttling
|
||||||
from .feature_flag import FeatureFlag
|
from .feature_flag import FeatureFlag
|
||||||
|
|
||||||
# pylint: disable=too-many-ancestors
|
# pylint: disable=too-many-ancestors
|
||||||
@@ -191,18 +191,6 @@ 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(
|
class RoomViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
@@ -379,7 +367,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="request-entry",
|
url_path="request-entry",
|
||||||
permission_classes=[],
|
permission_classes=[],
|
||||||
throttle_classes=[RequestEntryAnonRateThrottle],
|
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
|
||||||
)
|
)
|
||||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
"""Request entry to a room"""
|
"""Request entry to a room"""
|
||||||
@@ -489,7 +477,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="creation-callback",
|
url_path="creation-callback",
|
||||||
permission_classes=[],
|
permission_classes=[],
|
||||||
throttle_classes=[CreationCallbackAnonRateThrottle],
|
throttle_classes=[throttling.CreationCallbackAnonRateThrottle],
|
||||||
)
|
)
|
||||||
def creation_callback(self, request):
|
def creation_callback(self, request):
|
||||||
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import jwt as pyJwt
|
|||||||
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||||
from rest_framework import authentication, exceptions
|
from rest_framework import authentication, exceptions
|
||||||
|
|
||||||
|
from core.models import Application
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -94,6 +96,18 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
logger.warning("Missing 'client_id' in JWT payload")
|
logger.warning("Missing 'client_id' in JWT payload")
|
||||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
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:
|
if not is_delegated:
|
||||||
logger.warning("Token is not marked as delegated")
|
logger.warning("Token is not marked as delegated")
|
||||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||||
|
|||||||
@@ -33,12 +33,11 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
Raises:
|
Raises:
|
||||||
PermissionDenied: If required scope is missing from token
|
PermissionDenied: If required scope is missing from token
|
||||||
"""
|
"""
|
||||||
# Get the current action (e.g., 'list', 'create')
|
# Get the current action (e.g., 'list', 'create'), if None let DRF handle it
|
||||||
action = getattr(view, "action", None)
|
action = getattr(view, "action", None)
|
||||||
if not action:
|
if not action:
|
||||||
raise exceptions.PermissionDenied(
|
# DRF routers return a 405 for unsupported methods
|
||||||
"Insufficient permissions. Unknown action."
|
return True
|
||||||
)
|
|
||||||
|
|
||||||
required_scope = self.scope_map.get(action)
|
required_scope = self.scope_map.get(action)
|
||||||
if not required_scope:
|
if not required_scope:
|
||||||
@@ -57,9 +56,12 @@ class BaseScopePermission(permissions.BasePermission):
|
|||||||
if isinstance(token_scopes, str):
|
if isinstance(token_scopes, str):
|
||||||
token_scopes = token_scopes.split()
|
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:
|
if settings.OIDC_RS_SCOPES_PREFIX:
|
||||||
token_scopes = [
|
token_scopes = [
|
||||||
scope.replace(f"{settings.OIDC_RS_SCOPES_PREFIX}:", "")
|
scope.removeprefix(f"{settings.OIDC_RS_SCOPES_PREFIX}:")
|
||||||
for scope in token_scopes
|
for scope in token_scopes
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -82,3 +84,23 @@ class HasRequiredRoomScope(BaseScopePermission):
|
|||||||
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
|
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
|
||||||
"destroy": models.ApplicationScope.ROOMS_DELETE,
|
"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)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from logging import getLogger
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import check_password
|
from django.contrib.auth.hashers import check_password
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
@@ -28,7 +28,7 @@ from . import authentication, permissions, serializers
|
|||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ApplicationViewSet(viewsets.GenericViewSet):
|
class ApplicationViewSet(viewsets.ViewSet):
|
||||||
"""API endpoints for application authentication and token generation."""
|
"""API endpoints for application authentication and token generation."""
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
@@ -93,7 +93,7 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = models.User.objects.get(email=email)
|
user = models.User.objects.get(email__iexact=email)
|
||||||
except models.User.DoesNotExist as e:
|
except models.User.DoesNotExist as e:
|
||||||
if (
|
if (
|
||||||
settings.APPLICATION_ALLOW_USER_CREATION
|
settings.APPLICATION_ALLOW_USER_CREATION
|
||||||
@@ -123,6 +123,10 @@ class ApplicationViewSet(viewsets.GenericViewSet):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise drf_exceptions.NotFound("User not found.") from e
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
scope = " ".join(application.scopes or [])
|
scope = " ".join(application.scopes or [])
|
||||||
@@ -178,7 +182,9 @@ class RoomViewSet(
|
|||||||
ResourceServerAuthentication,
|
ResourceServerAuthentication,
|
||||||
]
|
]
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
|
api.permissions.IsAuthenticated
|
||||||
|
& permissions.HasRequiredRoomScope
|
||||||
|
& permissions.RoomPermissions
|
||||||
]
|
]
|
||||||
queryset = models.Room.objects.all()
|
queryset = models.Room.objects.all()
|
||||||
serializer_class = serializers.RoomSerializer
|
serializer_class = serializers.RoomSerializer
|
||||||
|
|||||||
@@ -292,6 +292,10 @@ class Resource(BaseModel):
|
|||||||
role = RoleChoices.MEMBER
|
role = RoleChoices.MEMBER
|
||||||
return role
|
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):
|
def is_administrator_or_owner(self, user):
|
||||||
"""
|
"""
|
||||||
Check if a user is administrator or owner of the resource."""
|
Check if a user is administrator or owner of the resource."""
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
|
|||||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||||
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = None
|
||||||
settings.EMAIL_FROM = "notifications@acme.com"
|
settings.EMAIL_FROM = "notifications@acme.com"
|
||||||
|
|
||||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||||
|
|||||||
@@ -2,21 +2,21 @@
|
|||||||
Tests for external API /room endpoint
|
Tests for external API /room endpoint
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0621
|
# pylint: disable=W0621,C0302
|
||||||
|
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
import responses
|
import responses
|
||||||
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core.factories import (
|
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||||
RoomFactory,
|
|
||||||
UserFactory,
|
|
||||||
)
|
|
||||||
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -27,12 +27,14 @@ def generate_test_token(user, scopes):
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
scope_string = " ".join(scopes)
|
scope_string = " ".join(scopes)
|
||||||
|
|
||||||
|
application = ApplicationFactory()
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||||
"client_id": "test-client-id",
|
"client_id": str(application.client_id),
|
||||||
"scope": scope_string,
|
"scope": scope_string,
|
||||||
"user_id": str(user.id),
|
"user_id": str(user.id),
|
||||||
"delegated": True,
|
"delegated": True,
|
||||||
@@ -53,6 +55,23 @@ def test_api_rooms_list_requires_authentication():
|
|||||||
assert response.status_code == 401
|
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):
|
def test_api_rooms_list_with_valid_token(settings):
|
||||||
"""Listing rooms with valid token should succeed."""
|
"""Listing rooms with valid token should succeed."""
|
||||||
|
|
||||||
@@ -73,6 +92,24 @@ def test_api_rooms_list_with_valid_token(settings):
|
|||||||
assert response.data["results"][0]["id"] == str(room.id)
|
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):
|
def test_api_rooms_list_with_expired_token(settings):
|
||||||
"""Listing rooms with expired token should return 401."""
|
"""Listing rooms with expired token should return 401."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||||
@@ -92,8 +129,8 @@ def test_api_rooms_list_with_expired_token(settings):
|
|||||||
|
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_api_rooms_list_with_invalid_token(settings):
|
def test_api_rooms_list_with_invalid_rs_token(settings):
|
||||||
"""Listing rooms with invalid token should return 400."""
|
"""Listing rooms with invalid resource server token should return 400."""
|
||||||
|
|
||||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
@@ -130,7 +167,27 @@ def test_api_rooms_list_missing_scope(settings):
|
|||||||
response = client.get("/external-api/v1.0/rooms/")
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
|
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()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_filters_by_user(settings):
|
def test_api_rooms_list_filters_by_user(settings):
|
||||||
@@ -144,7 +201,9 @@ def test_api_rooms_list_filters_by_user(settings):
|
|||||||
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
|
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
|
||||||
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
|
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
|
||||||
|
|
||||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
token = generate_test_token(
|
||||||
|
user1, [ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE]
|
||||||
|
)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
@@ -158,6 +217,84 @@ def test_api_rooms_list_filters_by_user(settings):
|
|||||||
assert str(room2.id) not in returned_ids
|
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):
|
def test_api_rooms_retrieve_requires_scope(settings):
|
||||||
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
|
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||||
@@ -178,6 +315,24 @@ 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):
|
def test_api_rooms_retrieve_success(settings):
|
||||||
"""Retrieving a room with correct scope should succeed."""
|
"""Retrieving a room with correct scope should succeed."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||||
@@ -212,6 +367,132 @@ 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):
|
def test_api_rooms_create_requires_scope(settings):
|
||||||
"""Creating a room requires ROOMS_CREATE scope."""
|
"""Creating a room requires ROOMS_CREATE scope."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||||
@@ -225,18 +506,38 @@ def test_api_rooms_create_requires_scope(settings):
|
|||||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert "Insufficient permissions. Required scope: rooms:create" in str(
|
assert (
|
||||||
response.data
|
"insufficient permissions. required scope: rooms:create"
|
||||||
|
in str(response.data).lower()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def test_api_rooms_create_success(settings):
|
||||||
"""Creating a room with correct scope should succeed."""
|
"""Creating a room with correct scope should succeed."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||||
|
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
token = generate_test_token(
|
||||||
|
user, [ApplicationScope.ROOMS_CREATE, ApplicationScope.ROOMS_LIST]
|
||||||
|
)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
@@ -245,6 +546,8 @@ def test_api_rooms_create_success(settings):
|
|||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
assert "id" in response.data
|
assert "id" in response.data
|
||||||
assert "slug" 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
|
# Verify room was created with user as owner
|
||||||
room = Room.objects.get(id=response.data["id"])
|
room = Room.objects.get(id=response.data["id"])
|
||||||
@@ -252,6 +555,72 @@ def test_api_rooms_create_success(settings):
|
|||||||
assert room.access_level == "trusted"
|
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):
|
def test_api_rooms_response_no_url(settings):
|
||||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||||
@@ -290,10 +659,43 @@ def test_api_rooms_response_no_telephony(settings):
|
|||||||
assert response.data["id"] == str(room.id)
|
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):
|
def test_api_rooms_token_without_delegated_flag(settings):
|
||||||
"""Token without delegated flag should be rejected."""
|
"""Token without delegated flag should be rejected."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
application = ApplicationFactory()
|
||||||
|
|
||||||
# Generate token without delegated flag
|
# Generate token without delegated flag
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -302,7 +704,7 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
|||||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + timedelta(hours=1),
|
"exp": now + timedelta(hours=1),
|
||||||
"client_id": "test-client",
|
"client_id": str(application.client_id),
|
||||||
"scope": "rooms:list",
|
"scope": "rooms:list",
|
||||||
"user_id": str(user.id),
|
"user_id": str(user.id),
|
||||||
"delegated": False, # Not delegated
|
"delegated": False, # Not delegated
|
||||||
@@ -318,7 +720,73 @@ def test_api_rooms_token_without_delegated_flag(settings):
|
|||||||
response = client.get("/external-api/v1.0/rooms/")
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert "Invalid token type." in str(response.data)
|
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
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_token_missing_client_id(settings):
|
def test_api_rooms_token_missing_client_id(settings):
|
||||||
@@ -348,7 +816,157 @@ def test_api_rooms_token_missing_client_id(settings):
|
|||||||
response = client.get("/external-api/v1.0/rooms/")
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert "Invalid token claims." in str(response.data)
|
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()
|
||||||
|
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
@@ -507,7 +1125,7 @@ def test_resource_server_authentication_successful(settings):
|
|||||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||||
"sub": "very-specific-sub",
|
"sub": "very-specific-sub",
|
||||||
"client_id": "some_service_provider",
|
"client_id": "some_service_provider",
|
||||||
"scope": "openid lasuite_meet lasuite_meet:rooms:list",
|
"scope": "openid lasuite_meet lasuite_meet:rooms:list lasuite_meet:rooms:retrieve",
|
||||||
"active": True,
|
"active": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pytestmark = pytest.mark.django_db
|
|||||||
def test_api_applications_generate_token_success(settings):
|
def test_api_applications_generate_token_success(settings):
|
||||||
"""Valid credentials should return a JWT token."""
|
"""Valid credentials should return a JWT token."""
|
||||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||||
user = UserFactory(email="user@example.com")
|
UserFactory(email="User.Family@example.com")
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
active=True,
|
active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
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_id": application.client_id,
|
||||||
"client_secret": plain_secret,
|
"client_secret": plain_secret,
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"scope": user.email,
|
"scope": "user.family@example.com",
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from socket import gethostbyname, gethostname
|
|||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import dj_database_url
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from configurations import Configuration, values
|
from configurations import Configuration, values
|
||||||
from lasuite.configuration.values import SecretFileValue
|
from lasuite.configuration.values import SecretFileValue
|
||||||
@@ -92,7 +93,11 @@ class Base(Configuration):
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": dj_database_url.config()
|
||||||
|
if values.DatabaseURLValue(
|
||||||
|
None, environ_name="DATABASE_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
else {
|
||||||
"ENGINE": values.Value(
|
"ENGINE": values.Value(
|
||||||
"django.db.backends.postgresql_psycopg2",
|
"django.db.backends.postgresql_psycopg2",
|
||||||
environ_name="DB_ENGINE",
|
environ_name="DB_ENGINE",
|
||||||
@@ -292,6 +297,9 @@ class Base(Configuration):
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
|
"core.api.throttling.sentry_monitoring_throttle_failure"
|
||||||
|
)
|
||||||
|
|
||||||
SPECTACULAR_SETTINGS = {
|
SPECTACULAR_SETTINGS = {
|
||||||
"TITLE": "Meet API",
|
"TITLE": "Meet API",
|
||||||
@@ -336,6 +344,9 @@ class Base(Configuration):
|
|||||||
"feedback": values.DictValue(
|
"feedback": values.DictValue(
|
||||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
{}, 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(
|
"use_french_gov_footer": values.BooleanValue(
|
||||||
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
|
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.1.0"
|
version = "1.7.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -29,6 +29,7 @@ dependencies = [
|
|||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
"brevo-python==1.2.0",
|
"brevo-python==1.2.0",
|
||||||
"celery[redis]==5.5.3",
|
"celery[redis]==5.5.3",
|
||||||
|
"dj-database-url==3.1.0",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.9.0",
|
"django-cors-headers==4.9.0",
|
||||||
"django-countries==8.0.0",
|
"django-countries==8.0.0",
|
||||||
@@ -38,7 +39,7 @@ dependencies = [
|
|||||||
"django-redis==6.0.0",
|
"django-redis==6.0.0",
|
||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django==5.2.9",
|
"django==5.2.11",
|
||||||
"djangorestframework==3.16.1",
|
"djangorestframework==3.16.1",
|
||||||
"drf_spectacular==0.29.0",
|
"drf_spectacular==0.29.0",
|
||||||
"dockerflow==2024.4.2",
|
"dockerflow==2024.4.2",
|
||||||
@@ -56,7 +57,7 @@ dependencies = [
|
|||||||
"whitenoise==6.11.0",
|
"whitenoise==6.11.0",
|
||||||
"mozilla-django-oidc==4.0.1",
|
"mozilla-django-oidc==4.0.1",
|
||||||
"livekit-api==1.0.7",
|
"livekit-api==1.0.7",
|
||||||
"aiohttp==3.13.2",
|
"aiohttp==3.13.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ server {
|
|||||||
|
|
||||||
root /usr/share/nginx/html;
|
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
|
# Serve static files with caching
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
|
|||||||
@@ -6,6 +6,22 @@
|
|||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
<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="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>%VITE_APP_TITLE%</title>
|
<title>%VITE_APP_TITLE%</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Generated
+515
-154
@@ -1,42 +1,42 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.1.0",
|
"version": "1.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.1.0",
|
"version": "1.7.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/material-symbols-outlined": "5.2.30",
|
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||||
"@fontsource/material-icons-outlined": "5.2.6",
|
"@fontsource/material-icons-outlined": "5.2.6",
|
||||||
"@livekit/components-react": "2.9.13",
|
"@livekit/components-react": "2.9.13",
|
||||||
"@livekit/components-styles": "1.1.6",
|
"@livekit/components-styles": "1.1.6",
|
||||||
"@livekit/track-processors": "0.6.1",
|
"@livekit/track-processors": "0.6.1",
|
||||||
"@pandacss/preset-panda": "0.54.0",
|
"@pandacss/preset-panda": "0.54.0",
|
||||||
"@react-aria/toast": "3.0.5",
|
"@react-aria/toast": "3.0.10",
|
||||||
"@react-types/overlays": "3.9.0",
|
"@react-types/overlays": "3.9.3",
|
||||||
"@remixicon/react": "4.6.0",
|
"@remixicon/react": "4.6.0",
|
||||||
"@tanstack/react-query": "5.81.5",
|
"@tanstack/react-query": "5.81.5",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.0.25",
|
"crisp-sdk-web": "1.0.27",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.0",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "25.3.1",
|
"i18next": "25.8.4",
|
||||||
"i18next-browser-languagedetector": "8.2.0",
|
"i18next-browser-languagedetector": "8.2.0",
|
||||||
"i18next-parser": "9.3.0",
|
"i18next-parser": "9.3.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"libphonenumber-js": "1.12.10",
|
"libphonenumber-js": "1.12.10",
|
||||||
"livekit-client": "2.15.7",
|
"livekit-client": "2.15.7",
|
||||||
"posthog-js": "1.256.2",
|
"posthog-js": "1.342.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.10.1",
|
"react-aria-components": "1.10.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "15.1.1",
|
"react-i18next": "15.1.1",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.1.5",
|
"valtio": "2.3.0",
|
||||||
"wouter": "3.7.1"
|
"wouter": "3.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@pandacss/dev": "0.54.0",
|
"@pandacss/dev": "0.54.0",
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
"eslint-plugin-react-refresh": "0.4.20",
|
"eslint-plugin-react-refresh": "0.4.20",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.8.1",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "7.0.8",
|
"vite": "7.0.8",
|
||||||
"vite-tsconfig-paths": "5.1.4"
|
"vite-tsconfig-paths": "5.1.4"
|
||||||
@@ -363,9 +363,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.27.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -1021,9 +1021,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource-variable/material-symbols-outlined": {
|
"node_modules/@fontsource-variable/material-symbols-outlined": {
|
||||||
"version": "5.2.30",
|
"version": "5.2.34",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.30.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.34.tgz",
|
||||||
"integrity": "sha512-BjSx7nqvISJs2Pjd8sBH583AnD4k6dD4Em7AVISoLXzbX3PIFWAE2GPm13LlCys8u8idkyUd62L8yn6ts6DdbA==",
|
"integrity": "sha512-FfJVoKdYlxvIHflVZlrevf6e1WmbJGeAdbcLmOmkftoP2Du0qrRD/SmNchIXijsKXF7j3z1m++e92Qi6/U+jRA==",
|
||||||
"license": "OFL-1.1",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
@@ -1158,9 +1158,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@internationalized/date": {
|
"node_modules/@internationalized/date": {
|
||||||
"version": "3.8.2",
|
"version": "3.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.11.0.tgz",
|
||||||
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
|
"integrity": "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
@@ -1177,9 +1177,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@internationalized/number": {
|
"node_modules/@internationalized/number": {
|
||||||
"version": "3.6.3",
|
"version": "3.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz",
|
||||||
"integrity": "sha512-p+Zh1sb6EfrfVaS86jlHGQ9HA66fJhV9x5LiE5vCbZtXEHAuhcmUZUdZ4WrFpUBfNalr2OkAJI5AcKEQF+Lebw==",
|
"integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
@@ -1354,6 +1354,252 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/@pandacss/config": {
|
||||||
"version": "0.54.0",
|
"version": "0.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pandacss/config/-/config-0.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pandacss/config/-/config-0.54.0.tgz",
|
||||||
@@ -1746,6 +1992,85 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-0.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-0.54.0.tgz",
|
||||||
"integrity": "sha512-5kspg2UOgFWrawbHeoleZvbZ6id/kIBLHoDeD1CnLbl9fEbZAZ3Avi5Hi1mIFe9E8PFzp9V+N9IfQL7pYhavfA=="
|
"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": {
|
"node_modules/@react-aria/autocomplete": {
|
||||||
"version": "3.0.0-beta.5",
|
"version": "3.0.0-beta.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-beta.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/autocomplete/-/autocomplete-3.0.0-beta.5.tgz",
|
||||||
@@ -2096,18 +2421,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/i18n": {
|
"node_modules/@react-aria/i18n": {
|
||||||
"version": "3.12.10",
|
"version": "3.12.15",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.10.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/i18n/-/i18n-3.12.15.tgz",
|
||||||
"integrity": "sha512-1j00soQ2W0nTgzaaIsGFdMF/5aN60AEdCJPhmXGZiuWdWzMxObN9LQ9vdzYPTjTqyqMdSaSp9DZKs5I26Xovpw==",
|
"integrity": "sha512-3CrAN7ORVHrckvTmbPq76jFZabqq+rScosGT5+ElircJ5rF5+JcdT99Hp5Xg6R10jk74e8G3xiqdYsUd+7iJMA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.8.2",
|
"@internationalized/date": "^3.11.0",
|
||||||
"@internationalized/message": "^3.1.8",
|
"@internationalized/message": "^3.1.8",
|
||||||
"@internationalized/number": "^3.6.3",
|
"@internationalized/number": "^3.6.5",
|
||||||
"@internationalized/string": "^3.2.7",
|
"@internationalized/string": "^3.2.7",
|
||||||
"@react-aria/ssr": "^3.9.9",
|
"@react-aria/ssr": "^3.9.10",
|
||||||
"@react-aria/utils": "^3.29.1",
|
"@react-aria/utils": "^3.33.0",
|
||||||
"@react-types/shared": "^3.30.0",
|
"@react-types/shared": "^3.33.0",
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -2116,15 +2441,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/interactions": {
|
"node_modules/@react-aria/interactions": {
|
||||||
"version": "3.25.3",
|
"version": "3.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.3.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.0.tgz",
|
||||||
"integrity": "sha512-J1bhlrNtjPS/fe5uJQ+0c7/jiXniwa4RQlP+Emjfc/iuqpW2RhbF9ou5vROcLzWIyaW8tVMZ468J68rAs/aZ5A==",
|
"integrity": "sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-aria/ssr": "^3.9.9",
|
"@react-aria/ssr": "^3.9.10",
|
||||||
"@react-aria/utils": "^3.29.1",
|
"@react-aria/utils": "^3.33.0",
|
||||||
"@react-stately/flags": "^3.1.2",
|
"@react-stately/flags": "^3.1.2",
|
||||||
"@react-types/shared": "^3.30.0",
|
"@react-types/shared": "^3.33.0",
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -2148,15 +2473,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/landmark": {
|
"node_modules/@react-aria/landmark": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/landmark/-/landmark-3.0.9.tgz",
|
||||||
"integrity": "sha512-1U5ce6cqg1qGbK4M4R6vwrhUrKXuUzReZwHaTrXxEY22IMxKDXIZL8G7pFpcKix2XKqjLZWf+g8ngGuNhtQ2QQ==",
|
"integrity": "sha512-YYyluDBCXupnMh91ccE5g27fczjYmzPebHqTkVYjH4B6k45pOoqsMmWBCMnOTl0qOCeioI+daT8W0MamAZzoSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-aria/utils": "^3.29.1",
|
"@react-aria/utils": "^3.33.0",
|
||||||
"@react-types/shared": "^3.30.0",
|
"@react-types/shared": "^3.33.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
@@ -2457,9 +2782,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/ssr": {
|
"node_modules/@react-aria/ssr": {
|
||||||
"version": "3.9.9",
|
"version": "3.9.10",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.9.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
|
||||||
"integrity": "sha512-2P5thfjfPy/np18e5wD4WPt8ydNXhij1jwA8oehxZTFqlgVMGXzcWKxTb4RtJrLFsqPO7RUQTiY8QJk0M4Vy2g==",
|
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
@@ -2579,18 +2904,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/toast": {
|
"node_modules/@react-aria/toast": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/toast/-/toast-3.0.10.tgz",
|
||||||
"integrity": "sha512-uhwiZqPy6hqucBUL7z6uUZjAJ/ou3bNdTjZlXS+zbcm+T0dsjKDfzNkaebyZY7AX3cYkFCaRjc3N6omXwoAviw==",
|
"integrity": "sha512-irW5Cr4msbPo4A4ysjT70MDJbpGCe1h9SkFgdYXBPA4Xbi4jRT7TiEZeIS1I7Hsvp6shAK1Ld/m6NBS0b/gyzg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-aria/i18n": "^3.12.10",
|
"@react-aria/i18n": "^3.12.15",
|
||||||
"@react-aria/interactions": "^3.25.3",
|
"@react-aria/interactions": "^3.27.0",
|
||||||
"@react-aria/landmark": "^3.0.4",
|
"@react-aria/landmark": "^3.0.9",
|
||||||
"@react-aria/utils": "^3.29.1",
|
"@react-aria/utils": "^3.33.0",
|
||||||
"@react-stately/toast": "^3.1.1",
|
"@react-stately/toast": "^3.1.3",
|
||||||
"@react-types/button": "^3.12.2",
|
"@react-types/button": "^3.15.0",
|
||||||
"@react-types/shared": "^3.30.0",
|
"@react-types/shared": "^3.33.0",
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -2672,15 +2997,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/utils": {
|
"node_modules/@react-aria/utils": {
|
||||||
"version": "3.29.1",
|
"version": "3.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.0.tgz",
|
||||||
"integrity": "sha512-yXMFVJ73rbQ/yYE/49n5Uidjw7kh192WNN9PNQGV0Xoc7EJUlSOxqhnpHmYTyO0EotJ8fdM1fMH8durHjUSI8g==",
|
"integrity": "sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-aria/ssr": "^3.9.9",
|
"@react-aria/ssr": "^3.9.10",
|
||||||
"@react-stately/flags": "^3.1.2",
|
"@react-stately/flags": "^3.1.2",
|
||||||
"@react-stately/utils": "^3.10.7",
|
"@react-stately/utils": "^3.11.0",
|
||||||
"@react-types/shared": "^3.30.0",
|
"@react-types/shared": "^3.33.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"clsx": "^2.0.0"
|
"clsx": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -3112,13 +3437,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-stately/toast": {
|
"node_modules/@react-stately/toast": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-stately/toast/-/toast-3.1.3.tgz",
|
||||||
"integrity": "sha512-W4a6xcsFt/E+aHmR2eZK+/p7Y5rdyXSCQ5gKSnbck+S3lijEWAyV45Mv8v95CQqu0bQijj6sy2Js1szq10HVwg==",
|
"integrity": "sha512-mT9QJKmD523lqFpOp0VWZ6QHZENFK7HrodnNJDVc7g616s5GNmemdlkITV43fSY3tHeThCVvPu+Uzh7RvQ9mpQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
@@ -3170,9 +3495,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-stately/utils": {
|
"node_modules/@react-stately/utils": {
|
||||||
"version": "3.10.7",
|
"version": "3.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.7.tgz",
|
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz",
|
||||||
"integrity": "sha512-cWvjGAocvy4abO9zbr6PW6taHgF24Mwy/LbQ4TC4Aq3tKdKDntxyD+sh7AkSRfJRT2ccMVaHVv2+FfHThd3PKQ==",
|
"integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
@@ -3224,12 +3549,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/button": {
|
"node_modules/@react-types/button": {
|
||||||
"version": "3.12.2",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/button/-/button-3.15.0.tgz",
|
||||||
"integrity": "sha512-QLoSCX8E7NFIdkVMa65TPieve0rKeltfcIxiMtrphjfNn+83L0IHMcbhjf4r4W19c/zqGbw3E53Hx8mNukoTUw==",
|
"integrity": "sha512-X/K2/Oeuq7Hi8nMIzx4/YlZuvWFiSOHZt27p4HmThCnNO/9IDFPmvPrpkYjWN5eN9Nuk+P5vZUb4A7QJgYpvGA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-types/shared": "^3.30.0"
|
"@react-types/shared": "^3.33.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
@@ -3399,12 +3724,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/overlays": {
|
"node_modules/@react-types/overlays": {
|
||||||
"version": "3.9.0",
|
"version": "3.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.3.tgz",
|
||||||
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
|
"integrity": "sha512-LzetThNNk8T26pQRbs1I7+isuFhdFYREy7wJCsZmbB0FnZgCukGTfOtThZWv+ry11veyVJiX68jfl4SV6ACTWA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-types/shared": "^3.31.0"
|
"@react-types/shared": "^3.33.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
@@ -3460,9 +3785,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/shared": {
|
"node_modules/@react-types/shared": {
|
||||||
"version": "3.31.0",
|
"version": "3.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.0.tgz",
|
||||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
"integrity": "sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
@@ -4023,7 +4348,6 @@
|
|||||||
"version": "22.16.0",
|
"version": "22.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.0.tgz",
|
||||||
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
|
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -4062,6 +4386,13 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/symlink-or-copy/-/symlink-or-copy-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/symlink-or-copy/-/symlink-or-copy-1.2.2.tgz",
|
||||||
"integrity": "sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA=="
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.35.1",
|
"version": "8.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz",
|
||||||
@@ -5089,15 +5420,16 @@
|
|||||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||||
},
|
},
|
||||||
"node_modules/crisp-sdk-web": {
|
"node_modules/crisp-sdk-web": {
|
||||||
"version": "1.0.25",
|
"version": "1.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/crisp-sdk-web/-/crisp-sdk-web-1.0.27.tgz",
|
||||||
"integrity": "sha512-CWTHFFeHRV0oqiXoPh/aIAKhFs6xcIM4NenGPnClAMCZUDQgQsF1OWmZWmnVNjJriXUmWRgDfeUxcxygS0dCRA=="
|
"integrity": "sha512-aNWR3te65YiaVFu/iwdqOo3cyUBZHUheE4d6EtgQu/T18jh/9SpoYXjXF/OzUD3Cqy0pGryoqtuy5gxD8tqX9Q==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-key": "^3.1.0",
|
"path-key": "^3.1.0",
|
||||||
"shebang-command": "^2.0.0",
|
"shebang-command": "^2.0.0",
|
||||||
@@ -5376,6 +5708,15 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"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": {
|
"node_modules/domutils": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
|
||||||
@@ -6565,15 +6906,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/humanize-duration": {
|
"node_modules/humanize-duration": {
|
||||||
"version": "3.33.0",
|
"version": "3.33.2",
|
||||||
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz",
|
||||||
"integrity": "sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==",
|
"integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==",
|
||||||
"license": "Unlicense"
|
"license": "Unlicense",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/EvanHahn"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "25.3.1",
|
"version": "25.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.4.tgz",
|
||||||
"integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==",
|
"integrity": "sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6590,7 +6934,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -7084,8 +7428,7 @@
|
|||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"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": {
|
"node_modules/javascript-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
@@ -7562,6 +7905,12 @@
|
|||||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
"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": {
|
"node_modules/look-it-up": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/look-it-up/-/look-it-up-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/look-it-up/-/look-it-up-2.1.0.tgz",
|
||||||
@@ -7739,7 +8088,7 @@
|
|||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -8043,7 +8392,6 @@
|
|||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -8069,7 +8417,7 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
@@ -8116,7 +8464,7 @@
|
|||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -8261,33 +8609,31 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.256.2",
|
"version": "1.342.1",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.256.2.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.342.1.tgz",
|
||||||
"integrity": "sha512-ypepnUHr33i5a1Uk39mozZXXTENRPC17HCG3WHKK6aRcpNwNs8uEqXaIKICGNM+qre+totKeTgl0WoaUFYmyoQ==",
|
"integrity": "sha512-mMnQhWuKj4ejFicLtFzr52InmqploOyW1eInqXBkaVqE1DPhczBDmwsd9MSggY8kv0EXm8zgK+2tzBJUKcX5yg==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"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",
|
"core-js": "^3.38.1",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.19.3",
|
"preact": "^10.28.2",
|
||||||
"web-vitals": "^4.2.4"
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
},
|
"web-vitals": "^5.1.0"
|
||||||
"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": {
|
"node_modules/preact": {
|
||||||
"version": "10.24.0",
|
"version": "10.28.3",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
||||||
"integrity": "sha512-aK8Cf+jkfyuZ0ZZRG9FbYqwmEiGQ4y/PUO4SuTWoyWL244nZZh7bd5h2APd4rSNDYTBNghg1L+5iJN3Skxtbsw==",
|
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
||||||
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
@@ -8303,9 +8649,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prettier": {
|
"node_modules/prettier": {
|
||||||
"version": "3.6.2",
|
"version": "3.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -8331,6 +8677,30 @@
|
|||||||
"node": "10.* || >= 12.*"
|
"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": {
|
"node_modules/proxy-compare": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.1.tgz",
|
||||||
@@ -8346,6 +8716,12 @@
|
|||||||
"node": ">=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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -8747,17 +9123,6 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/rsvp": {
|
||||||
"version": "4.8.5",
|
"version": "4.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
|
||||||
@@ -8915,7 +9280,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shebang-regex": "^3.0.0"
|
"shebang-regex": "^3.0.0"
|
||||||
},
|
},
|
||||||
@@ -8927,7 +9291,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -8993,7 +9356,7 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9540,7 +9903,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
@@ -9614,9 +9976,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.4.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
"integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==",
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@@ -9643,9 +10005,9 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
},
|
},
|
||||||
"node_modules/valtio": {
|
"node_modules/valtio": {
|
||||||
"version": "2.1.5",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.3.0.tgz",
|
||||||
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
|
"integrity": "sha512-1MfKNcmOIdBSatiJsYgw420n6jnD+jeoI0V+RkOQbCB0ElLh6GKUfPr0hc9uq/KBGeghivDEarRsKFFdSQQnKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"proxy-compare": "^3.0.1"
|
"proxy-compare": "^3.0.1"
|
||||||
@@ -9931,9 +10293,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/web-vitals": {
|
"node_modules/web-vitals": {
|
||||||
"version": "4.2.4",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
||||||
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
|
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
@@ -9971,7 +10333,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"isexe": "^2.0.0"
|
"isexe": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -10037,9 +10398,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wouter": {
|
"node_modules/wouter": {
|
||||||
"version": "3.7.1",
|
"version": "3.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/wouter/-/wouter-3.9.0.tgz",
|
||||||
"integrity": "sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw==",
|
"integrity": "sha512-sF/od/PIgqEQBQcrN7a2x3MX6MQE6nW0ygCfy9hQuUkuB28wEZuu/6M5GyqkrrEu9M6jxdkgE12yDFsQMKos4Q==",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
|
|||||||
+11
-11
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -13,35 +13,35 @@
|
|||||||
"check": "prettier --check ./src"
|
"check": "prettier --check ./src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/material-symbols-outlined": "5.2.30",
|
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||||
"@fontsource/material-icons-outlined": "5.2.6",
|
"@fontsource/material-icons-outlined": "5.2.6",
|
||||||
"@livekit/components-react": "2.9.13",
|
"@livekit/components-react": "2.9.13",
|
||||||
"@livekit/components-styles": "1.1.6",
|
"@livekit/components-styles": "1.1.6",
|
||||||
"@livekit/track-processors": "0.6.1",
|
"@livekit/track-processors": "0.6.1",
|
||||||
"@pandacss/preset-panda": "0.54.0",
|
"@pandacss/preset-panda": "0.54.0",
|
||||||
"@react-aria/toast": "3.0.5",
|
"@react-aria/toast": "3.0.10",
|
||||||
"@react-types/overlays": "3.9.0",
|
"@react-types/overlays": "3.9.3",
|
||||||
"@remixicon/react": "4.6.0",
|
"@remixicon/react": "4.6.0",
|
||||||
"@tanstack/react-query": "5.81.5",
|
"@tanstack/react-query": "5.81.5",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.0.25",
|
"crisp-sdk-web": "1.0.27",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.0",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "25.3.1",
|
"i18next": "25.8.4",
|
||||||
"i18next-browser-languagedetector": "8.2.0",
|
"i18next-browser-languagedetector": "8.2.0",
|
||||||
"i18next-parser": "9.3.0",
|
"i18next-parser": "9.3.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"libphonenumber-js": "1.12.10",
|
"libphonenumber-js": "1.12.10",
|
||||||
"livekit-client": "2.15.7",
|
"livekit-client": "2.15.7",
|
||||||
"posthog-js": "1.256.2",
|
"posthog-js": "1.342.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.10.1",
|
"react-aria-components": "1.10.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "15.1.1",
|
"react-i18next": "15.1.1",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.1.5",
|
"valtio": "2.3.0",
|
||||||
"wouter": "3.7.1"
|
"wouter": "3.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@pandacss/dev": "0.54.0",
|
"@pandacss/dev": "0.54.0",
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
"eslint-plugin-react-refresh": "0.4.20",
|
"eslint-plugin-react-refresh": "0.4.20",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.8.1",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "7.0.8",
|
"vite": "7.0.8",
|
||||||
"vite-tsconfig-paths": "5.1.4"
|
"vite-tsconfig-paths": "5.1.4"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"packageFamilyName" : "Visio_g3z6ba6vek6vg",
|
||||||
|
"paths" : [ "*" ]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -17,6 +17,7 @@ export interface ApiConfig {
|
|||||||
feedback: {
|
feedback: {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
external_home_url?: string
|
||||||
silence_livekit_debug_logs?: boolean
|
silence_livekit_debug_logs?: boolean
|
||||||
is_silent_login_enabled?: boolean
|
is_silent_login_enabled?: boolean
|
||||||
custom_css_url?: string
|
custom_css_url?: string
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ export const BlurOnStrong = () => {
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export const IntroSlider = () => {
|
|||||||
<SlideContainer>
|
<SlideContainer>
|
||||||
{SLIDES.map((slide, index) => (
|
{SLIDES.map((slide, index) => (
|
||||||
<Slide visible={index == slideIndex} key={index}>
|
<Slide visible={index == slideIndex} key={index}>
|
||||||
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
|
<Image src={slide.src} alt="" role="presentation" />
|
||||||
<TextAnimation visible={index == slideIndex}>
|
<TextAnimation visible={index == slideIndex}>
|
||||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||||
<Body>{t(`${slide.key}.body`)}</Body>
|
<Body>{t(`${slide.key}.body`)}</Body>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { RiAddLine, RiLink } from '@remixicon/react'
|
|||||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||||
import { ReactNode, useState } from 'react'
|
import { ReactNode, useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||||
@@ -19,6 +19,7 @@ import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePers
|
|||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||||
|
|
||||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
@@ -155,9 +156,34 @@ export const Home = () => {
|
|||||||
|
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||||
|
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||||
|
|
||||||
const { data } = useConfig()
|
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 (
|
return (
|
||||||
<UserAware>
|
<UserAware>
|
||||||
<Screen>
|
<Screen>
|
||||||
|
|||||||
@@ -25,15 +25,27 @@ export const TermsOfServiceRoute = () => {
|
|||||||
{/* Article 2 */}
|
{/* Article 2 */}
|
||||||
<H lvl={2}>{t('articles.article2.title')}</H>
|
<H lvl={2}>{t('articles.article2.title')}</H>
|
||||||
<P>{t('articles.article2.content')}</P>
|
<P>{t('articles.article2.content')}</P>
|
||||||
<P>{t('articles.article2.purposes')}</P>
|
{ensureArray(
|
||||||
|
t('articles.article2.paragraphs', {
|
||||||
|
returnObjects: true,
|
||||||
|
})
|
||||||
|
).map((paragraph, index) => (
|
||||||
|
<P key={index}>{paragraph}</P>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Article 3 */}
|
{/* Article 3 */}
|
||||||
<H lvl={2}>{t('articles.article3.title')}</H>
|
<H lvl={2}>{t('articles.article3.title')}</H>
|
||||||
<P>{t('articles.article3.definition')}</P>
|
{ensureArray(
|
||||||
|
t('articles.article3.paragraphs', {
|
||||||
|
returnObjects: true,
|
||||||
|
})
|
||||||
|
).map((paragraph, index) => (
|
||||||
|
<P key={index}>{paragraph}</P>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Article 4 */}
|
{/* Article 4 */}
|
||||||
<H lvl={2}>{t('articles.article4.title')}</H>
|
<H lvl={2}>{t('articles.article4.title')}</H>
|
||||||
<P>{t('articles.article4.content')}</P>
|
<P>{t('articles.article4.definition')}</P>
|
||||||
|
|
||||||
{/* Article 5 */}
|
{/* Article 5 */}
|
||||||
<H lvl={2} margin={false}>
|
<H lvl={2} margin={false}>
|
||||||
@@ -62,6 +74,7 @@ export const TermsOfServiceRoute = () => {
|
|||||||
__html: t('articles.article5.sections.section1.paragraph3'),
|
__html: t('articles.article5.sections.section1.paragraph3'),
|
||||||
}}
|
}}
|
||||||
></P>
|
></P>
|
||||||
|
<P>{t('articles.article5.sections.section1.paragraph4')}</P>
|
||||||
|
|
||||||
{/* Section 5.2 */}
|
{/* Section 5.2 */}
|
||||||
<H lvl={3} bold>
|
<H lvl={3} bold>
|
||||||
@@ -111,15 +124,12 @@ export const TermsOfServiceRoute = () => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Article 7 */}
|
{/* Article 7 */}
|
||||||
<H lvl={2} margin={false}>
|
<H lvl={2}>{t('articles.article7.title')}</H>
|
||||||
{t('articles.article7.title')}
|
|
||||||
</H>
|
|
||||||
|
|
||||||
{/* Section 7.1 */}
|
{/* Section 7.1 */}
|
||||||
<H lvl={3} bold>
|
<H lvl={3} bold>
|
||||||
{t('articles.article7.sections.section1.title')}
|
{t('articles.article7.sections.section1.title')}
|
||||||
</H>
|
</H>
|
||||||
<P>{t('articles.article7.sections.section1.content')}</P>
|
|
||||||
{ensureArray(
|
{ensureArray(
|
||||||
t('articles.article7.sections.section1.paragraphs', {
|
t('articles.article7.sections.section1.paragraphs', {
|
||||||
returnObjects: true,
|
returnObjects: true,
|
||||||
@@ -132,16 +142,51 @@ export const TermsOfServiceRoute = () => {
|
|||||||
<H lvl={3} bold>
|
<H lvl={3} bold>
|
||||||
{t('articles.article7.sections.section2.title')}
|
{t('articles.article7.sections.section2.title')}
|
||||||
</H>
|
</H>
|
||||||
|
{ensureArray(
|
||||||
|
t('articles.article7.sections.section2.paragraphs', {
|
||||||
|
returnObjects: true,
|
||||||
|
})
|
||||||
|
).map((paragraph, index) => (
|
||||||
|
<P key={index}>{paragraph}</P>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Section 7.3 */}
|
||||||
|
<H lvl={3} bold>
|
||||||
|
{t('articles.article7.sections.section3.title')}
|
||||||
|
</H>
|
||||||
|
{ensureArray(
|
||||||
|
t('articles.article7.sections.section3.paragraphs', {
|
||||||
|
returnObjects: true,
|
||||||
|
})
|
||||||
|
).map((paragraph, index) => (
|
||||||
|
<P key={index}>{paragraph}</P>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Section 7.4 */}
|
||||||
|
<H lvl={3} bold>
|
||||||
|
{t('articles.article7.sections.section4.title')}
|
||||||
|
</H>
|
||||||
|
{ensureArray(
|
||||||
|
t('articles.article7.sections.section4.paragraphs', {
|
||||||
|
returnObjects: true,
|
||||||
|
})
|
||||||
|
).map((paragraph, index) => (
|
||||||
|
<P key={index}>{paragraph}</P>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Section 7.5 */}
|
||||||
|
<H lvl={3} bold>
|
||||||
|
{t('articles.article7.sections.section5.title')}
|
||||||
|
</H>
|
||||||
<P>
|
<P>
|
||||||
{t('articles.article7.sections.section2.content')
|
{t('articles.article7.sections.section5.content')
|
||||||
.split('https://github.com/suitenumerique/meet')[0]
|
.split('https://github.com/suitenumerique/meet')[0]
|
||||||
.replace('https://github.com/suitenumerique/meet', '')}{' '}
|
.replace('https://github.com/suitenumerique/meet', '')}{' '}
|
||||||
<A href="https://github.com/suitenumerique/meet" color="primary">
|
<A href="https://github.com/suitenumerique/meet" color="primary">
|
||||||
https://github.com/suitenumerique/meet
|
https://github.com/suitenumerique/meet
|
||||||
</A>
|
</A>
|
||||||
{'. '}
|
|
||||||
{
|
{
|
||||||
t('articles.article7.sections.section2.content').split(
|
t('articles.article7.sections.section5.content').split(
|
||||||
'https://github.com/suitenumerique/meet'
|
'https://github.com/suitenumerique/meet'
|
||||||
)[1]
|
)[1]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { css, cx } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
import { Button, Text } from '@/primitives'
|
import { Button, Icon, Text } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
|
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
|
||||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
@@ -42,6 +42,17 @@ export const ControlsButton = ({
|
|||||||
}: ControlsButtonProps) => {
|
}: ControlsButtonProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
|
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
|
||||||
|
|
||||||
|
// Focus management: focus the primary action button when this side panel opens.
|
||||||
|
const primaryActionRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (primaryActionRef.current) {
|
||||||
|
primaryActionRef.current.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const isRoomConnected = room.state == ConnectionState.Connected
|
const isRoomConnected = room.state == ConnectionState.Connected
|
||||||
|
|
||||||
@@ -97,6 +108,7 @@ export const ControlsButton = ({
|
|||||||
fullWidth
|
fullWidth
|
||||||
onPress={handle}
|
onPress={handle}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
ref={primaryActionRef}
|
||||||
>
|
>
|
||||||
{t('button.stop')}
|
{t('button.stop')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -129,31 +141,23 @@ export const ControlsButton = ({
|
|||||||
})}
|
})}
|
||||||
onPress={() => openSidePanel()}
|
onPress={() => openSidePanel()}
|
||||||
>
|
>
|
||||||
<span
|
<Icon
|
||||||
className={cx(
|
className={css({
|
||||||
'material-icons',
|
color: 'primary.500',
|
||||||
css({
|
marginRight: '1rem',
|
||||||
color: 'primary.500',
|
})}
|
||||||
marginRight: '1rem',
|
name="info"
|
||||||
})
|
/>
|
||||||
)}
|
|
||||||
>
|
|
||||||
info
|
|
||||||
</span>
|
|
||||||
<Text variant={'smNote'}>
|
<Text variant={'smNote'}>
|
||||||
{parseLineBreaks(t('button.anotherModeStarted'))}
|
{parseLineBreaks(t('button.anotherModeStarted'))}
|
||||||
</Text>
|
</Text>
|
||||||
<span
|
<Icon
|
||||||
className={cx(
|
className={css({
|
||||||
'material-icons',
|
color: 'primary.500',
|
||||||
css({
|
marginLeft: 'auto',
|
||||||
color: 'primary.500',
|
})}
|
||||||
marginLeft: 'auto',
|
name="chevron_right"
|
||||||
})
|
/>
|
||||||
)}
|
|
||||||
>
|
|
||||||
chevron_right
|
|
||||||
</span>
|
|
||||||
</RACButton>
|
</RACButton>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@@ -162,6 +166,7 @@ export const ControlsButton = ({
|
|||||||
onPress={handle}
|
onPress={handle}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
size="compact"
|
size="compact"
|
||||||
|
ref={primaryActionRef}
|
||||||
>
|
>
|
||||||
{t('button.start')}
|
{t('button.start')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { H, Text } from '@/primitives'
|
import { H, Text, Icon } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
@@ -24,9 +24,7 @@ export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
||||||
<span className="material-symbols" aria-hidden={true}>
|
<Icon type="symbols" name="login" />
|
||||||
login
|
|
||||||
</span>
|
|
||||||
<H lvl={3} margin={false} padding={false}>
|
<H lvl={3} margin={false} padding={false}>
|
||||||
{heading}
|
{heading}
|
||||||
</H>
|
</H>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMemo } from 'react'
|
import { useMemo, useRef, useEffect } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import {
|
import {
|
||||||
RecordingMode,
|
RecordingMode,
|
||||||
@@ -13,6 +13,7 @@ import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|||||||
import { useRoomMetadata } from '../hooks/useRoomMetadata'
|
import { useRoomMetadata } from '../hooks/useRoomMetadata'
|
||||||
import { RecordingStatusIcon } from './RecordingStatusIcon'
|
import { RecordingStatusIcon } from './RecordingStatusIcon'
|
||||||
import { useIsRecording } from '@livekit/components-react'
|
import { useIsRecording } from '@livekit/components-react'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
export const RecordingStateToast = () => {
|
export const RecordingStateToast = () => {
|
||||||
const { t } = useTranslation('rooms', {
|
const { t } = useTranslation('rooms', {
|
||||||
@@ -21,6 +22,9 @@ export const RecordingStateToast = () => {
|
|||||||
|
|
||||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||||
|
|
||||||
|
const lastKeyRef = useRef('')
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
FeatureFlags.Transcript
|
FeatureFlags.Transcript
|
||||||
@@ -67,6 +71,16 @@ export const RecordingStateToast = () => {
|
|||||||
return `${metadata.recording_mode}.${status}`
|
return `${metadata.recording_mode}.${status}`
|
||||||
}, [metadata, isStarted, isStarting, isRecording])
|
}, [metadata, isStarted, isStarting, isRecording])
|
||||||
|
|
||||||
|
// Update screen reader message only when the key actually changes
|
||||||
|
// This prevents duplicate announcements caused by re-renders
|
||||||
|
useEffect(() => {
|
||||||
|
if (key && key !== lastKeyRef.current) {
|
||||||
|
lastKeyRef.current = key
|
||||||
|
const message = t(key)
|
||||||
|
announce(message)
|
||||||
|
}
|
||||||
|
}, [announce, key, t])
|
||||||
|
|
||||||
if (!key) return null
|
if (!key) return null
|
||||||
|
|
||||||
const hasScreenRecordingAccessAndActive =
|
const hasScreenRecordingAccessAndActive =
|
||||||
@@ -74,61 +88,65 @@ export const RecordingStateToast = () => {
|
|||||||
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className={css({
|
{/* Visual banner (without aria-live to avoid duplicate announcements) */}
|
||||||
display: 'flex',
|
<div
|
||||||
position: 'fixed',
|
className={css({
|
||||||
top: '10px',
|
display: 'flex',
|
||||||
left: '10px',
|
position: 'fixed',
|
||||||
paddingY: '0.25rem',
|
top: '10px',
|
||||||
paddingX: '0.75rem 0.75rem',
|
left: '10px',
|
||||||
backgroundColor: 'danger.700',
|
paddingY: '0.25rem',
|
||||||
borderColor: 'white',
|
paddingX: '0.75rem 0.75rem',
|
||||||
border: '1px solid',
|
backgroundColor: 'danger.700',
|
||||||
color: 'white',
|
borderColor: 'white',
|
||||||
borderRadius: '4px',
|
border: '1px solid',
|
||||||
gap: '0.5rem',
|
color: 'white',
|
||||||
})}
|
borderRadius: '4px',
|
||||||
>
|
gap: '0.5rem',
|
||||||
<RecordingStatusIcon
|
})}
|
||||||
isStarted={isStarted}
|
>
|
||||||
isTranscriptActive={isTranscriptActive}
|
<RecordingStatusIcon
|
||||||
/>
|
isStarted={isStarted}
|
||||||
|
isTranscriptActive={isTranscriptActive}
|
||||||
|
/>
|
||||||
|
|
||||||
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
|
{!hasScreenRecordingAccessAndActive &&
|
||||||
<Text
|
!hasTranscriptAccessAndActive && (
|
||||||
variant={'sm'}
|
<Text
|
||||||
className={css({
|
variant={'sm'}
|
||||||
fontWeight: '500 !important',
|
className={css({
|
||||||
})}
|
fontWeight: '500 !important',
|
||||||
>
|
})}
|
||||||
{t(key)}
|
>
|
||||||
</Text>
|
{t(key)}
|
||||||
)}
|
</Text>
|
||||||
{hasScreenRecordingAccessAndActive && (
|
)}
|
||||||
<RACButton
|
{hasScreenRecordingAccessAndActive && (
|
||||||
onPress={openScreenRecording}
|
<RACButton
|
||||||
className={css({
|
onPress={openScreenRecording}
|
||||||
textStyle: 'sm !important',
|
className={css({
|
||||||
fontWeight: '500 !important',
|
textStyle: 'sm !important',
|
||||||
cursor: 'pointer',
|
fontWeight: '500 !important',
|
||||||
})}
|
cursor: 'pointer',
|
||||||
>
|
})}
|
||||||
{t(key)}
|
>
|
||||||
</RACButton>
|
{t(key)}
|
||||||
)}
|
</RACButton>
|
||||||
{hasTranscriptAccessAndActive && (
|
)}
|
||||||
<RACButton
|
{hasTranscriptAccessAndActive && (
|
||||||
onPress={openTranscript}
|
<RACButton
|
||||||
className={css({
|
onPress={openTranscript}
|
||||||
textStyle: 'sm !important',
|
className={css({
|
||||||
fontWeight: '500 !important',
|
textStyle: 'sm !important',
|
||||||
cursor: 'pointer',
|
fontWeight: '500 !important',
|
||||||
})}
|
cursor: 'pointer',
|
||||||
>
|
})}
|
||||||
{t(key)}
|
>
|
||||||
</RACButton>
|
{t(key)}
|
||||||
)}
|
</RACButton>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
|
import { Icon } from '@/primitives'
|
||||||
|
|
||||||
interface RecordingStatusIconProps {
|
interface RecordingStatusIconProps {
|
||||||
isStarted: boolean
|
isStarted: boolean
|
||||||
@@ -14,8 +15,8 @@ export const RecordingStatusIcon = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isTranscriptActive) {
|
if (isTranscriptActive) {
|
||||||
return <span className="material-symbols">speech_to_text</span>
|
return <Icon type="symbols" name="speech_to_text" />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span className="material-symbols">screen_record</span>
|
return <Icon type="symbols" name="screen_record" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, H, Text } from '@/primitives'
|
import { Button, Icon, H, Text } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
@@ -59,7 +59,7 @@ export const RequestRecording = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
||||||
<span className="material-symbols">person_raised_hand</span>
|
<Icon type="symbols" name="person_raised_hand" />
|
||||||
<H lvl={3} margin={false} padding={false}>
|
<H lvl={3} margin={false} padding={false}>
|
||||||
{heading}
|
{heading}
|
||||||
</H>
|
</H>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
import { Icon } from '@/primitives'
|
||||||
|
|
||||||
type RowPosition = 'first' | 'middle' | 'last' | 'single'
|
type RowPosition = 'first' | 'middle' | 'last' | 'single'
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ export const RowWrapper = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* fixme - doesn't handle properly material-symbols */}
|
{/* fixme - doesn't handle properly material-symbols */}
|
||||||
<span className="material-icons">{iconName}</span>
|
<Icon name={iconName} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
|||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
RecordingMode,
|
RecordingMode,
|
||||||
useHasFeatureWithoutAdminRights,
|
|
||||||
useHumanizeRecordingMaxDuration,
|
useHumanizeRecordingMaxDuration,
|
||||||
useRecordingStatuses,
|
useRecordingStatuses,
|
||||||
} from '@/features/recording'
|
} from '@/features/recording'
|
||||||
@@ -19,7 +18,6 @@ import {
|
|||||||
} from '@/features/notifications'
|
} from '@/features/notifications'
|
||||||
import posthog from 'posthog-js'
|
import posthog from 'posthog-js'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { FeatureFlags } from '@/features/analytics/enums'
|
|
||||||
import { NoAccessView } from './NoAccessView'
|
import { NoAccessView } from './NoAccessView'
|
||||||
import { ControlsButton } from './ControlsButton'
|
import { ControlsButton } from './ControlsButton'
|
||||||
import { RowWrapper } from './RowWrapper'
|
import { RowWrapper } from './RowWrapper'
|
||||||
@@ -28,6 +26,7 @@ import { Checkbox } from '@/primitives/Checkbox'
|
|||||||
import { useTranscriptionLanguage } from '@/features/settings'
|
import { useTranscriptionLanguage } from '@/features/settings'
|
||||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner.ts'
|
||||||
|
|
||||||
export const ScreenRecordingSidePanel = () => {
|
export const ScreenRecordingSidePanel = () => {
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
@@ -38,10 +37,7 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
|
|
||||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||||
|
|
||||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
RecordingMode.ScreenRecording,
|
|
||||||
FeatureFlags.ScreenRecording
|
|
||||||
)
|
|
||||||
|
|
||||||
const { notifyParticipants } = useNotifyParticipants()
|
const { notifyParticipants } = useNotifyParticipants()
|
||||||
const { selectedLanguageKey, isLanguageSetToAuto } =
|
const { selectedLanguageKey, isLanguageSetToAuto } =
|
||||||
@@ -98,14 +94,17 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.ScreenRecordingStarted,
|
type: NotificationType.ScreenRecordingStarted,
|
||||||
})
|
})
|
||||||
posthog.capture('screen-recording-started', {})
|
posthog.capture('screen-recording-started', {
|
||||||
|
includeTranscript: includeTranscript,
|
||||||
|
language: selectedLanguageKey,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to handle recording:', error)
|
console.error('Failed to handle recording:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFeatureWithoutAdminRights) {
|
if (!isAdminOrOwner) {
|
||||||
return (
|
return (
|
||||||
<NoAccessView
|
<NoAccessView
|
||||||
i18nKeyPrefix={keyPrefix}
|
i18nKeyPrefix={keyPrefix}
|
||||||
@@ -146,7 +145,7 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<VStack gap={0} marginBottom={30}>
|
<VStack gap={0} marginBottom={15}>
|
||||||
<H lvl={1} margin={'sm'} fullWidth>
|
<H lvl={1} margin={'sm'} fullWidth>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
@@ -163,7 +162,7 @@ export const ScreenRecordingSidePanel = () => {
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
<VStack gap={0} marginBottom={40}>
|
<VStack gap={0} marginBottom={25}>
|
||||||
<RowWrapper iconName="cloud_download" position="first">
|
<RowWrapper iconName="cloud_download" position="first">
|
||||||
<Text variant="sm">{t('details.destination')}</Text>
|
<Text variant="sm">{t('details.destination')}</Text>
|
||||||
</RowWrapper>
|
</RowWrapper>
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ export const TranscriptSidePanel = () => {
|
|||||||
await notifyParticipants({
|
await notifyParticipants({
|
||||||
type: NotificationType.TranscriptionStarted,
|
type: NotificationType.TranscriptionStarted,
|
||||||
})
|
})
|
||||||
posthog.capture('transcript-started', {})
|
posthog.capture('transcript-started', {
|
||||||
|
includeScreenRecording: includeScreenRecording,
|
||||||
|
language: selectedLanguageKey,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to handle transcript:', error)
|
console.error('Failed to handle transcript:', error)
|
||||||
@@ -178,7 +181,7 @@ export const TranscriptSidePanel = () => {
|
|||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<VStack gap={0} marginBottom={30}>
|
<VStack gap={0} marginBottom={15}>
|
||||||
<H lvl={1} margin={'sm'}>
|
<H lvl={1} margin={'sm'}>
|
||||||
{t('heading')}
|
{t('heading')}
|
||||||
</H>
|
</H>
|
||||||
@@ -195,7 +198,7 @@ export const TranscriptSidePanel = () => {
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
<VStack gap={0} marginBottom={40}>
|
<VStack gap={0} marginBottom={25}>
|
||||||
<RowWrapper iconName="article" position="first">
|
<RowWrapper iconName="article" position="first">
|
||||||
<Text variant="sm">
|
<Text variant="sm">
|
||||||
{data?.transcription_destination ? (
|
{data?.transcription_destination ? (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const useHumanizeRecordingMaxDuration = () => {
|
|||||||
|
|
||||||
return humanizeDuration(data?.recording?.max_duration, {
|
return humanizeDuration(data?.recording?.max_duration, {
|
||||||
language: i18n.language,
|
language: i18n.language,
|
||||||
|
delimiter: ' ',
|
||||||
})
|
})
|
||||||
}, [data])
|
}, [data])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,14 +104,12 @@ export const RecordingDownload = () => {
|
|||||||
{t('success.title')}
|
{t('success.title')}
|
||||||
</H>
|
</H>
|
||||||
<Text centered margin="md" wrap={'balance'}>
|
<Text centered margin="md" wrap={'balance'}>
|
||||||
<span
|
<span>
|
||||||
dangerouslySetInnerHTML={{
|
{t('success.body', {
|
||||||
__html: t('success.body', {
|
room: data.room.name,
|
||||||
room: data.room.name,
|
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
|
||||||
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
|
})}
|
||||||
}),
|
</span>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>
|
<span>
|
||||||
{configData?.recording?.expiration_days && (
|
{configData?.recording?.expiration_days && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||||
import { LocalUserChoices } from '@/stores/userChoices'
|
import { LocalUserChoices } from '@/stores/userChoices'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
|
||||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||||
import { usePostHog } from 'posthog-js/react'
|
import { usePostHog } from 'posthog-js/react'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { isFireFox } from '@/utils/livekit'
|
import { isFireFox } from '@/utils/livekit'
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -228,10 +228,20 @@ export const Conference = ({
|
|||||||
posthog.captureException(e)
|
posthog.captureException(e)
|
||||||
}}
|
}}
|
||||||
onDisconnected={(e) => {
|
onDisconnected={(e) => {
|
||||||
if (e == DisconnectReason.CLIENT_INITIATED) {
|
switch (e) {
|
||||||
navigateTo('feedback', { duplicateIdentity: false })
|
case DisconnectReason.CLIENT_INITIATED:
|
||||||
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
|
navigateTo('feedback')
|
||||||
navigateTo('feedback', { duplicateIdentity: true })
|
return
|
||||||
|
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||||
|
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||||
|
navigateTo(
|
||||||
|
'feedback',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
state: { reason: e },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMediaDeviceFailure={(e, kind) => {
|
onMediaDeviceFailure={(e, kind) => {
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
square
|
square
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
onPress={copyRoomUrlToClipboard}
|
onPress={copyRoomUrlToClipboard}
|
||||||
aria-label={t('copyUrl')}
|
aria-label={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
tooltip={t('copyUrl')}
|
tooltip={isRoomUrlCopied ? t('copied') : t('copyUrl')}
|
||||||
>
|
>
|
||||||
{isRoomUrlCopied ? (
|
{isRoomUrlCopied ? (
|
||||||
<RiCheckLine aria-hidden="true" />
|
<RiCheckLine aria-hidden="true" />
|
||||||
@@ -138,11 +138,12 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
{formatPinCode(roomData?.pin_code)}
|
{formatPinCode(roomData?.pin_code)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={isCopied ? 'success' : 'secondaryText'}
|
variant={isCopied ? 'success' : 'secondaryText'}
|
||||||
size="sm"
|
size="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-label={t('copy')}
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
style={{
|
style={{
|
||||||
justifyContent: 'start',
|
justifyContent: 'start',
|
||||||
}}
|
}}
|
||||||
@@ -173,7 +174,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
<Button
|
<Button
|
||||||
variant={isCopied ? 'success' : 'tertiary'}
|
variant={isCopied ? 'success' : 'tertiary'}
|
||||||
fullWidth
|
fullWidth
|
||||||
aria-label={t('copy')}
|
aria-label={isCopied ? t('copied') : t('copy')}
|
||||||
onPress={copyRoomToClipboard}
|
onPress={copyRoomToClipboard}
|
||||||
data-attr="share-dialog-copy"
|
data-attr="share-dialog-copy"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -446,16 +446,14 @@ export const Join = ({
|
|||||||
type="text"
|
type="text"
|
||||||
onChange={saveUsername}
|
onChange={saveUsername}
|
||||||
label={t('usernameLabel')}
|
label={t('usernameLabel')}
|
||||||
aria-label={t('usernameLabel')}
|
id="input-name"
|
||||||
defaultValue={username}
|
defaultValue={username}
|
||||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||||
wrapperProps={{
|
wrapperProps={{
|
||||||
noMargin: true,
|
noMargin: true,
|
||||||
fullWidth: true,
|
fullWidth: true,
|
||||||
}}
|
}}
|
||||||
labelProps={{
|
autoComplete="name"
|
||||||
center: true,
|
|
||||||
}}
|
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
/>
|
/>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
import { Button, Dialog, H, P } from '@/primitives'
|
import { A, Button, Dialog, H, P, ScreenReaderAnnouncer } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import humanizeDuration from 'humanize-duration'
|
import humanizeDuration from 'humanize-duration'
|
||||||
import i18n from 'i18next'
|
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 IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||||
|
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
|
||||||
|
const FINAL_COUNTDOWN_SECONDS = 10
|
||||||
|
|
||||||
export const IsIdleDisconnectModal = () => {
|
export const IsIdleDisconnectModal = () => {
|
||||||
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
||||||
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
|
const [timeRemaining, setTimeRemaining] = useState(IDLE_DISCONNECT_TIMEOUT_MS)
|
||||||
|
const lastAnnouncementRef = useRef<number | null>(null)
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'isIdleDisconnectModal' })
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
|
if (connectionObserverSnap.isIdleDisconnectModalOpen) {
|
||||||
@@ -35,10 +43,42 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
}
|
}
|
||||||
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
}, [connectionObserverSnap.isIdleDisconnectModalOpen])
|
||||||
|
|
||||||
const minutes = Math.floor(timeRemaining / 1000 / 60)
|
useEffect(() => {
|
||||||
const seconds = (timeRemaining / 1000) % 60
|
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 formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
|
isOpen={connectionObserverSnap.isIdleDisconnectModalOpen}
|
||||||
@@ -52,6 +92,7 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<ScreenReaderAnnouncer channel="idle" />
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
height: '50px',
|
height: '50px',
|
||||||
@@ -65,6 +106,7 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
color: 'blue.800',
|
color: 'blue.800',
|
||||||
margin: 'auto',
|
margin: 'auto',
|
||||||
})}
|
})}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{formattedTime}
|
{formattedTime}
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +120,19 @@ export const IsIdleDisconnectModal = () => {
|
|||||||
}),
|
}),
|
||||||
})}
|
})}
|
||||||
</P>
|
</P>
|
||||||
<P>{t('settings')}</P>
|
<P>
|
||||||
|
{t('settingsPrefix')}{' '}
|
||||||
|
<A
|
||||||
|
color="primary"
|
||||||
|
onPress={() => {
|
||||||
|
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||||
|
openSettingsDialog(SettingsDialogExtendedKey.GENERAL)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settingsLink')}
|
||||||
|
</A>
|
||||||
|
{t('settingsSuffix')}
|
||||||
|
</P>
|
||||||
<HStack marginTop="2rem">
|
<HStack marginTop="2rem">
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ export const ParticipantTile: (
|
|||||||
'aria-label': t('containerLabel', { name: participantName }),
|
'aria-label': t('containerLabel', { name: participantName }),
|
||||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
elementProps.onFocus?.(event)
|
elementProps.onFocus?.(event)
|
||||||
setHasKeyboardFocus(true)
|
const target = event.target as HTMLElement | null
|
||||||
|
const isFocusVisible = !!target?.matches?.(':focus-visible')
|
||||||
|
setHasKeyboardFocus(isFocusVisible)
|
||||||
},
|
},
|
||||||
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
|
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
elementProps.onBlur?.(event)
|
elementProps.onBlur?.(event)
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { css } from '@/styled-system/css'
|
|||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
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_DURATION = 3000
|
||||||
export const ANIMATION_DISTANCE = 300
|
export const ANIMATION_DISTANCE = 300
|
||||||
@@ -140,11 +144,41 @@ export function ReactionPortal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
|
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
||||||
reactions.map((instance) => (
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
<ReactionPortal
|
const { announceReactions } = useSnapshot(accessibilityStore)
|
||||||
key={instance.id}
|
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
||||||
emoji={instance.emoji}
|
const announce = useScreenReaderAnnounce()
|
||||||
participant={instance.participant}
|
|
||||||
/>
|
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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode, useEffect, useRef } from 'react'
|
||||||
import { Chat } from '../prefabs/Chat'
|
import { Chat } from '../prefabs/Chat'
|
||||||
import { Effects } from './effects/Effects'
|
import { Effects } from './effects/Effects'
|
||||||
import { Admin } from './Admin'
|
import { Admin } from './Admin'
|
||||||
import { Tools } from './Tools'
|
import { Tools } from './Tools'
|
||||||
import { Info } from './Info'
|
import { Info } from './Info'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
const SIDE_PANEL_HEADING_ID = 'side-panel-heading'
|
||||||
|
const SIDE_PANEL_CLOSE_ID = 'side-panel-close'
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -22,6 +26,7 @@ type StyledSidePanelProps = {
|
|||||||
closeButtonTooltip: string
|
closeButtonTooltip: string
|
||||||
isSubmenu: boolean
|
isSubmenu: boolean
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
|
backButtonLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledSidePanel = ({
|
const StyledSidePanel = ({
|
||||||
@@ -32,8 +37,16 @@ const StyledSidePanel = ({
|
|||||||
closeButtonTooltip,
|
closeButtonTooltip,
|
||||||
isSubmenu = false,
|
isSubmenu = false,
|
||||||
onBack,
|
onBack,
|
||||||
|
backButtonLabel,
|
||||||
}: StyledSidePanelProps) => (
|
}: StyledSidePanelProps) => (
|
||||||
<div
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- role="dialog" makes this interactive
|
||||||
|
<aside
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby={!isClosed ? SIDE_PANEL_HEADING_ID : undefined}
|
||||||
|
aria-hidden={isClosed || undefined}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}}
|
||||||
className={css({
|
className={css({
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
@@ -59,52 +72,57 @@ const StyledSidePanel = ({
|
|||||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Heading
|
<HStack alignItems="center">
|
||||||
slot="title"
|
{isSubmenu && (
|
||||||
level={1}
|
<Button
|
||||||
className={text({ variant: 'h2' })}
|
variant="secondaryText"
|
||||||
style={{
|
size="sm"
|
||||||
paddingLeft: '1.5rem',
|
square
|
||||||
paddingTop: '1rem',
|
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||||
display: isClosed ? 'none' : 'flex',
|
aria-label={backButtonLabel}
|
||||||
justifyContent: 'start',
|
onPress={onBack}
|
||||||
alignItems: 'center',
|
>
|
||||||
}}
|
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||||
>
|
</Button>
|
||||||
{isSubmenu && (
|
)}
|
||||||
<Button
|
<Heading
|
||||||
variant="secondaryText"
|
id={SIDE_PANEL_HEADING_ID}
|
||||||
size={'sm'}
|
slot="title"
|
||||||
square
|
level={1}
|
||||||
className={css({ marginRight: '0.5rem' })}
|
className={text({ variant: 'h2' })}
|
||||||
onPress={onBack}
|
style={{
|
||||||
|
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
display: isClosed ? 'none' : 'flex',
|
||||||
|
justifyContent: 'start',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RiArrowLeftLine size={20} />
|
{title}
|
||||||
</Button>
|
</Heading>
|
||||||
)}
|
</HStack>
|
||||||
{title}
|
<Div
|
||||||
</Heading>
|
position="absolute"
|
||||||
<Div
|
top="5"
|
||||||
position="absolute"
|
right="5"
|
||||||
top="5"
|
style={{
|
||||||
right="5"
|
display: isClosed ? 'none' : undefined,
|
||||||
style={{
|
}}
|
||||||
display: isClosed ? 'none' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
invisible
|
|
||||||
variant="tertiaryText"
|
|
||||||
size="xs"
|
|
||||||
onPress={onClose}
|
|
||||||
aria-label={closeButtonTooltip}
|
|
||||||
tooltip={closeButtonTooltip}
|
|
||||||
>
|
>
|
||||||
<RiCloseLine />
|
<Button
|
||||||
</Button>
|
id={SIDE_PANEL_CLOSE_ID}
|
||||||
</Div>
|
invisible
|
||||||
{children}
|
variant="tertiaryText"
|
||||||
</div>
|
size="xs"
|
||||||
|
onPress={onClose}
|
||||||
|
aria-label={closeButtonTooltip}
|
||||||
|
tooltip={closeButtonTooltip}
|
||||||
|
>
|
||||||
|
<RiCloseLine />
|
||||||
|
</Button>
|
||||||
|
</Div>
|
||||||
|
{children}
|
||||||
|
</aside>
|
||||||
)
|
)
|
||||||
|
|
||||||
type PanelProps = {
|
type PanelProps = {
|
||||||
@@ -141,18 +159,57 @@ export const SidePanel = () => {
|
|||||||
} = useSidePanel()
|
} = useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||||
|
|
||||||
|
const triggerRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// The aside stays mounted (CSS slide + keepAlive), so we manually handle
|
||||||
|
// auto-focus on open and restore focus on close (via handleClose).
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSidePanelOpen) return
|
||||||
|
const active = document.activeElement as HTMLElement
|
||||||
|
// Menu items render as DIVs that unmount when the menu closes — resolve to the menu trigger
|
||||||
|
triggerRef.current =
|
||||||
|
active?.tagName === 'DIV'
|
||||||
|
? (document.querySelector<HTMLElement>('#room-options-trigger') ??
|
||||||
|
active)
|
||||||
|
: active
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const closeBtn = document.getElementById(SIDE_PANEL_CLOSE_ID)
|
||||||
|
// Skip if a child panel already moved focus inside (e.g. Chat input)
|
||||||
|
if (closeBtn?.closest('aside')?.contains(document.activeElement)) return
|
||||||
|
closeBtn?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
}, [isSidePanelOpen])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
const trigger = triggerRef.current
|
||||||
|
triggerRef.current = null
|
||||||
|
layoutStore.activePanelId = null
|
||||||
|
layoutStore.activeSubPanelId = null
|
||||||
|
// Double RAF: first lets React re-render, second lets FocusScope release containment
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (trigger?.isConnected) {
|
||||||
|
trigger.focus({ preventScroll: true })
|
||||||
|
} else {
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>('#room-options-trigger')
|
||||||
|
?.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||||
onClose={() => {
|
onClose={handleClose}
|
||||||
layoutStore.activePanelId = null
|
|
||||||
layoutStore.activeSubPanelId = null
|
|
||||||
}}
|
|
||||||
closeButtonTooltip={t('closeButton', {
|
closeButtonTooltip={t('closeButton', {
|
||||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||||
})}
|
})}
|
||||||
isClosed={!isSidePanelOpen}
|
isClosed={!isSidePanelOpen}
|
||||||
isSubmenu={isSubPanelOpen}
|
isSubmenu={isSubPanelOpen}
|
||||||
|
backButtonLabel={t('backToTools')}
|
||||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||||
>
|
>
|
||||||
<Panel isOpen={isParticipantsOpen}>
|
<Panel isOpen={isParticipantsOpen}>
|
||||||
@@ -164,7 +221,7 @@ export const SidePanel = () => {
|
|||||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||||
<Chat />
|
<Chat />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isToolsOpen}>
|
<Panel isOpen={isToolsOpen} keepAlive={true}>
|
||||||
<Tools />
|
<Tools />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel isOpen={isAdminOpen}>
|
<Panel isOpen={isAdminOpen}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { A, Div, Text } from '@/primitives'
|
import { A, Div, Icon, Text } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -86,7 +86,7 @@ const ToolButton = ({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span className="material-symbols">chevron_forward</span>
|
<Icon type="symbols" name="chevron_forward" />
|
||||||
</div>
|
</div>
|
||||||
</RACButton>
|
</RACButton>
|
||||||
)
|
)
|
||||||
@@ -146,7 +146,7 @@ export const Tools = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
{isTranscriptEnabled && (
|
{isTranscriptEnabled && (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
icon={<span className="material-symbols">speech_to_text</span>}
|
icon={<Icon type="symbols" name="speech_to_text" />}
|
||||||
title={t('tools.transcript.title')}
|
title={t('tools.transcript.title')}
|
||||||
description={t('tools.transcript.body')}
|
description={t('tools.transcript.body')}
|
||||||
onPress={() => openTranscript()}
|
onPress={() => openTranscript()}
|
||||||
@@ -154,7 +154,7 @@ export const Tools = () => {
|
|||||||
)}
|
)}
|
||||||
{isScreenRecordingEnabled && (
|
{isScreenRecordingEnabled && (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
icon={<span className="material-symbols">mode_standby</span>}
|
icon={<Icon type="symbols" name="mode_standby" />}
|
||||||
title={t('tools.screenRecording.title')}
|
title={t('tools.screenRecording.title')}
|
||||||
description={t('tools.screenRecording.body')}
|
description={t('tools.screenRecording.body')}
|
||||||
onPress={() => openScreenRecording()}
|
onPress={() => openScreenRecording()}
|
||||||
|
|||||||
+1
-3
@@ -11,9 +11,7 @@ import {
|
|||||||
ProcessorType,
|
ProcessorType,
|
||||||
} from '.'
|
} from '.'
|
||||||
|
|
||||||
export class UnifiedBackgroundTrackProcessor
|
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||||
implements BackgroundProcessorInterface
|
|
||||||
{
|
|
||||||
processor: ProcessorWrapper<BackgroundOptions>
|
processor: ProcessorWrapper<BackgroundOptions>
|
||||||
opts: BackgroundOptions
|
opts: BackgroundOptions
|
||||||
processorType: ProcessorType
|
processorType: ProcessorType
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ export interface ProcessorSerialized {
|
|||||||
options: BackgroundOptions
|
options: BackgroundOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundProcessorInterface
|
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
||||||
extends TrackProcessor<Track.Kind> {
|
|
||||||
update(opts: BackgroundOptions): Promise<void>
|
update(opts: BackgroundOptions): Promise<void>
|
||||||
options: BackgroundOptions
|
options: BackgroundOptions
|
||||||
clone(): BackgroundProcessorInterface
|
clone(): BackgroundProcessorInterface
|
||||||
|
|||||||
@@ -80,10 +80,12 @@ export const ChatInput = ({
|
|||||||
<TextArea
|
<TextArea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
e.stopPropagation()
|
if (e.key !== 'Escape') e.stopPropagation()
|
||||||
submitOnEnter(e)
|
submitOnEnter(e)
|
||||||
}}
|
}}
|
||||||
onKeyUp={(e) => e.stopPropagation()}
|
onKeyUp={(e) => {
|
||||||
|
if (e.key !== 'Escape') e.stopPropagation()
|
||||||
|
}}
|
||||||
placeholder={t('textArea.placeholder')}
|
placeholder={t('textArea.placeholder')}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ToggleButton } from '@/primitives'
|
|||||||
import { chatStore } from '@/stores/chat'
|
import { chatStore } from '@/stores/chat'
|
||||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
|
||||||
export const ChatToggle = ({
|
export const ChatToggle = ({
|
||||||
onPress,
|
onPress,
|
||||||
@@ -18,6 +19,11 @@ export const ChatToggle = ({
|
|||||||
const { isChatOpen, toggleChat } = useSidePanel()
|
const { isChatOpen, toggleChat } = useSidePanel()
|
||||||
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'toggle-chat',
|
||||||
|
handler: toggleChat,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
|||||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||||
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
|
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
|
||||||
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
|
|
||||||
type ToggleDeviceStyleProps = {
|
type ToggleDeviceStyleProps = {
|
||||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||||
@@ -88,12 +89,14 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
const deviceShortcut = useDeviceShortcut(kind)
|
const deviceShortcut = useDeviceShortcut(kind)
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
useRegisterKeyboardShortcut({
|
||||||
shortcut: deviceShortcut,
|
id: deviceShortcut?.id,
|
||||||
handler: async () => await toggle(),
|
handler: async () => await toggle(),
|
||||||
isDisabled: cannotUseDevice,
|
isDisabled: cannotUseDevice,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pushToTalkShortcut = getShortcutDescriptorById('push-to-talk')
|
||||||
useLongPress({
|
useLongPress({
|
||||||
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
|
keyCode: kind === 'audioinput' ? pushToTalkShortcut?.code : undefined,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
isDisabled: cannotUseDevice,
|
isDisabled: cannotUseDevice,
|
||||||
@@ -103,7 +106,9 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
const label = t(enabled ? 'disable' : 'enable', {
|
const label = t(enabled ? 'disable' : 'enable', {
|
||||||
keyPrefix: `selectDevice.${kind}`,
|
keyPrefix: `selectDevice.${kind}`,
|
||||||
})
|
})
|
||||||
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
|
return deviceShortcut?.shortcut
|
||||||
|
? appendShortcutLabel(label, deviceShortcut.shortcut)
|
||||||
|
: label
|
||||||
}, [enabled, kind, deviceShortcut, t])
|
}, [enabled, kind, deviceShortcut, t])
|
||||||
|
|
||||||
const Icon =
|
const Icon =
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
closeLowerHandToasts,
|
closeLowerHandToasts,
|
||||||
showLowerHandToast,
|
showLowerHandToast,
|
||||||
} from '@/features/notifications/utils'
|
} from '@/features/notifications/utils'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
|
||||||
const SPEAKING_DETECTION_DELAY = 3000
|
const SPEAKING_DETECTION_DELAY = 3000
|
||||||
|
|
||||||
@@ -33,6 +34,16 @@ export const HandToggle = () => {
|
|||||||
closeLowerHandToasts()
|
closeLowerHandToasts()
|
||||||
}, [isHandRaised])
|
}, [isHandRaised])
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
toggleRaisedHand()
|
||||||
|
resetToastState()
|
||||||
|
}
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'raise-hand',
|
||||||
|
handler: handleToggle,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
|
const shouldShowToast = isSpeaking && isHandRaised && !hasShownToast
|
||||||
|
|
||||||
@@ -68,10 +79,7 @@ export const HandToggle = () => {
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isHandRaised}
|
isSelected={isHandRaised}
|
||||||
onPress={() => {
|
onPress={handleToggle}
|
||||||
toggleRaisedHand()
|
|
||||||
resetToastState()
|
|
||||||
}}
|
|
||||||
data-attr={`controls-hand-${tooltipLabel}`}
|
data-attr={`controls-hand-${tooltipLabel}`}
|
||||||
>
|
>
|
||||||
<RiHand />
|
<RiHand />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const OptionsButton = () => {
|
|||||||
return (
|
return (
|
||||||
<Menu variant="dark">
|
<Menu variant="dark">
|
||||||
<Button
|
<Button
|
||||||
|
id="room-options-trigger"
|
||||||
square
|
square
|
||||||
variant="primaryDark"
|
variant="primaryDark"
|
||||||
aria-label={t('options.buttonLabel')}
|
aria-label={t('options.buttonLabel')}
|
||||||
|
|||||||
+27
-15
@@ -1,10 +1,12 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
||||||
import { ToggleButton } from '@/primitives'
|
import { ToggleButton } from '@/primitives'
|
||||||
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useParticipants } from '@livekit/components-react'
|
import { useParticipants } from '@livekit/components-react'
|
||||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
|
||||||
export const ParticipantsToggle = ({
|
export const ParticipantsToggle = ({
|
||||||
onPress,
|
onPress,
|
||||||
@@ -19,11 +21,18 @@ export const ParticipantsToggle = ({
|
|||||||
*/
|
*/
|
||||||
const participants = useParticipants()
|
const participants = useParticipants()
|
||||||
const numParticipants = participants?.length
|
const numParticipants = participants?.length
|
||||||
|
const announcedCount =
|
||||||
|
numParticipants && numParticipants > 0 ? numParticipants : 1
|
||||||
|
|
||||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||||
|
|
||||||
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'toggle-participants',
|
||||||
|
handler: toggleParticipants,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
@@ -31,21 +40,24 @@ export const ParticipantsToggle = ({
|
|||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ToggleButton
|
<VisualOnlyTooltip tooltip={t(tooltipLabel)}>
|
||||||
square
|
<ToggleButton
|
||||||
variant="primaryTextDark"
|
square
|
||||||
aria-label={t(tooltipLabel)}
|
variant="primaryTextDark"
|
||||||
tooltip={t(tooltipLabel)}
|
aria-label={`${t(tooltipLabel)}. ${t('count', {
|
||||||
isSelected={isParticipantsOpen}
|
count: announcedCount,
|
||||||
onPress={(e) => {
|
})}.`}
|
||||||
toggleParticipants()
|
isSelected={isParticipantsOpen}
|
||||||
onPress?.(e)
|
onPress={(e) => {
|
||||||
}}
|
toggleParticipants()
|
||||||
data-attr={`controls-participants-${tooltipLabel}`}
|
onPress?.(e)
|
||||||
{...props}
|
}}
|
||||||
>
|
data-attr={`controls-participants-${tooltipLabel}`}
|
||||||
<RiGroupLine />
|
{...props}
|
||||||
</ToggleButton>
|
>
|
||||||
|
<RiGroupLine />
|
||||||
|
</ToggleButton>
|
||||||
|
</VisualOnlyTooltip>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
ANIMATION_DURATION,
|
ANIMATION_DURATION,
|
||||||
ReactionPortals,
|
ReactionPortals,
|
||||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
} 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 { Toolbar as RACToolbar } from 'react-aria-components'
|
||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||||
@@ -40,6 +42,11 @@ export const ReactionsToggle = () => {
|
|||||||
|
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'reaction',
|
||||||
|
handler: () => setIsVisible((prev) => !prev),
|
||||||
|
})
|
||||||
|
|
||||||
const sendReaction = async (emoji: string) => {
|
const sendReaction = async (emoji: string) => {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const payload: NotificationPayload = {
|
const payload: NotificationPayload = {
|
||||||
@@ -145,7 +152,7 @@ export const ReactionsToggle = () => {
|
|||||||
<Button
|
<Button
|
||||||
key={index}
|
key={index}
|
||||||
onPress={() => debouncedSendReaction(emoji)}
|
onPress={() => debouncedSendReaction(emoji)}
|
||||||
aria-label={t('send', { emoji })}
|
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||||
variant="primaryTextDark"
|
variant="primaryTextDark"
|
||||||
size="sm"
|
size="sm"
|
||||||
square
|
square
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import {
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface AllowMediaPlaybackProps
|
export interface AllowMediaPlaybackProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
label?: string
|
label?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+180
-85
@@ -9,15 +9,16 @@ import {
|
|||||||
} from '../blur'
|
} from '../blur'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { H, P, Text, ToggleButton } from '@/primitives'
|
import { H, P, Text, ToggleButton } from '@/primitives'
|
||||||
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { BlurOn } from '@/components/icons/BlurOn'
|
import { BlurOn } from '@/components/icons/BlurOn'
|
||||||
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
|
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
|
||||||
import { useTrackToggle } from '@livekit/components-react'
|
import { useTrackToggle } from '@livekit/components-react'
|
||||||
import { Loader } from '@/primitives/Loader'
|
import { Loader } from '@/primitives/Loader'
|
||||||
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||||
import { RiProhibited2Line } from '@remixicon/react'
|
|
||||||
import { FunnyEffects } from './FunnyEffects'
|
import { FunnyEffects } from './FunnyEffects'
|
||||||
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
enum BlurRadius {
|
enum BlurRadius {
|
||||||
NONE = 0,
|
NONE = 0,
|
||||||
@@ -50,11 +51,17 @@ export const EffectsConfiguration = ({
|
|||||||
layout = 'horizontal',
|
layout = 'horizontal',
|
||||||
}: EffectsConfigurationProps) => {
|
}: EffectsConfigurationProps) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const blurLightRef = useRef<HTMLButtonElement | null>(null)
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
||||||
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
|
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
|
||||||
const [processorPending, setProcessorPending] = useState(false)
|
const [processorPending, setProcessorPending] = useState(false)
|
||||||
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
||||||
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
const effectAnnouncementTimeout = useRef<ReturnType<
|
||||||
|
typeof setTimeout
|
||||||
|
> | null>(null)
|
||||||
|
const effectAnnouncementId = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const videoElement = videoRef.current
|
const videoElement = videoRef.current
|
||||||
@@ -69,16 +76,92 @@ export const EffectsConfiguration = ({
|
|||||||
}
|
}
|
||||||
}, [videoTrack, videoTrack?.isMuted])
|
}, [videoTrack, videoTrack?.isMuted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!blurLightRef.current) return
|
||||||
|
|
||||||
|
const rafId = requestAnimationFrame(() => {
|
||||||
|
blurLightRef.current?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (effectAnnouncementTimeout.current) {
|
||||||
|
clearTimeout(effectAnnouncementTimeout.current)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const announceEffectStatusMessage = (message: string) => {
|
||||||
|
effectAnnouncementId.current += 1
|
||||||
|
const currentId = effectAnnouncementId.current
|
||||||
|
|
||||||
|
if (effectAnnouncementTimeout.current) {
|
||||||
|
clearTimeout(effectAnnouncementTimeout.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||||
|
if (currentId !== effectAnnouncementId.current) return
|
||||||
|
announce(message)
|
||||||
|
}, 80)
|
||||||
|
}
|
||||||
|
|
||||||
const clearEffect = async () => {
|
const clearEffect = async () => {
|
||||||
await videoTrack.stopProcessor()
|
await videoTrack.stopProcessor()
|
||||||
onSubmit?.(undefined)
|
onSubmit?.(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getVirtualBackgroundName = (imagePath?: string) => {
|
||||||
|
if (!imagePath) return ''
|
||||||
|
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
|
||||||
|
if (!match) return ''
|
||||||
|
const index = Number(match[1]) - 1
|
||||||
|
if (Number.isNaN(index)) return ''
|
||||||
|
return t(`virtual.descriptions.${index}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEffectStatusMessage = (
|
||||||
|
type: ProcessorType,
|
||||||
|
options: BackgroundOptions,
|
||||||
|
wasSelectedBeforeToggle: boolean
|
||||||
|
) => {
|
||||||
|
if (wasSelectedBeforeToggle) {
|
||||||
|
announceEffectStatusMessage(t('blur.status.none'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === ProcessorType.BLUR) {
|
||||||
|
const message =
|
||||||
|
options.blurRadius === BlurRadius.LIGHT
|
||||||
|
? t('blur.status.light')
|
||||||
|
: t('blur.status.strong')
|
||||||
|
announceEffectStatusMessage(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === ProcessorType.VIRTUAL) {
|
||||||
|
const backgroundName = getVirtualBackgroundName(options.imagePath)
|
||||||
|
if (backgroundName) {
|
||||||
|
announceEffectStatusMessage(
|
||||||
|
`${t('virtual.selectedLabel')} ${backgroundName}`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleEffect = async (
|
const toggleEffect = async (
|
||||||
type: ProcessorType,
|
type: ProcessorType,
|
||||||
options: BackgroundOptions
|
options: BackgroundOptions
|
||||||
) => {
|
) => {
|
||||||
setProcessorPending(true)
|
setProcessorPending(true)
|
||||||
|
const wasSelectedBeforeToggle = isSelected(type, options)
|
||||||
|
|
||||||
if (!videoTrack) {
|
if (!videoTrack) {
|
||||||
/**
|
/**
|
||||||
* Special case: if no video track is available, then we must pass directly the processor into the
|
* Special case: if no video track is available, then we must pass directly the processor into the
|
||||||
@@ -104,7 +187,7 @@ export const EffectsConfiguration = ({
|
|||||||
|
|
||||||
const processor = getProcessor()
|
const processor = getProcessor()
|
||||||
try {
|
try {
|
||||||
if (isSelected(type, options)) {
|
if (wasSelectedBeforeToggle) {
|
||||||
// Stop processor.
|
// Stop processor.
|
||||||
await clearEffect()
|
await clearEffect()
|
||||||
} else if (
|
} else if (
|
||||||
@@ -131,6 +214,8 @@ export const EffectsConfiguration = ({
|
|||||||
// We want to trigger onSubmit when options changes so the parent component is aware of it.
|
// We want to trigger onSubmit when options changes so the parent component is aware of it.
|
||||||
onSubmit?.(processor)
|
onSubmit?.(processor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error applying effect:', error)
|
console.error('Error applying effect:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -153,8 +238,28 @@ export const EffectsConfiguration = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
|
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
|
||||||
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
|
const strength =
|
||||||
|
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
|
||||||
|
const action = isSelected(type, options) ? 'clear' : 'apply'
|
||||||
|
|
||||||
|
return t(`${type}.${strength}.${action}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ariaLabelVirtualBackground = (
|
||||||
|
index: number,
|
||||||
|
imagePath: string
|
||||||
|
): string => {
|
||||||
|
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
|
||||||
|
imagePath,
|
||||||
|
})
|
||||||
|
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
|
||||||
|
const backgroundName = t(`virtual.descriptions.${index}`)
|
||||||
|
return `${t(`virtual.${prefix}`)} ${backgroundName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipVirtualBackground = (index: number): string => {
|
||||||
|
return t(`virtual.descriptions.${index}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -265,65 +370,57 @@ export const EffectsConfiguration = ({
|
|||||||
>
|
>
|
||||||
{t('blur.title')}
|
{t('blur.title')}
|
||||||
</H>
|
</H>
|
||||||
<div
|
<div>
|
||||||
className={css({
|
<div
|
||||||
display: 'flex',
|
className={css({
|
||||||
gap: '1.25rem',
|
display: 'flex',
|
||||||
})}
|
gap: '1.25rem',
|
||||||
>
|
})}
|
||||||
<ToggleButton
|
|
||||||
variant="bigSquare"
|
|
||||||
aria-label={t('clear')}
|
|
||||||
onPress={async () => {
|
|
||||||
await clearEffect()
|
|
||||||
}}
|
|
||||||
isSelected={!getProcessor()}
|
|
||||||
isDisabled={processorPendingReveal || isDisabled}
|
|
||||||
>
|
>
|
||||||
<RiProhibited2Line />
|
<ToggleButton
|
||||||
</ToggleButton>
|
ref={blurLightRef}
|
||||||
<ToggleButton
|
variant="bigSquare"
|
||||||
variant="bigSquare"
|
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||||
aria-label={tooltipLabel(ProcessorType.BLUR, {
|
|
||||||
blurRadius: BlurRadius.LIGHT,
|
|
||||||
})}
|
|
||||||
tooltip={tooltipLabel(ProcessorType.BLUR, {
|
|
||||||
blurRadius: BlurRadius.LIGHT,
|
|
||||||
})}
|
|
||||||
isDisabled={processorPendingReveal || isDisabled}
|
|
||||||
onChange={async () =>
|
|
||||||
await toggleEffect(ProcessorType.BLUR, {
|
|
||||||
blurRadius: BlurRadius.LIGHT,
|
blurRadius: BlurRadius.LIGHT,
|
||||||
})
|
})}
|
||||||
}
|
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||||
isSelected={isSelected(ProcessorType.BLUR, {
|
blurRadius: BlurRadius.LIGHT,
|
||||||
blurRadius: BlurRadius.LIGHT,
|
})}
|
||||||
})}
|
isDisabled={processorPendingReveal || isDisabled}
|
||||||
data-attr="toggle-blur-light"
|
onChange={async () =>
|
||||||
>
|
await toggleEffect(ProcessorType.BLUR, {
|
||||||
<BlurOn />
|
blurRadius: BlurRadius.LIGHT,
|
||||||
</ToggleButton>
|
})
|
||||||
<ToggleButton
|
}
|
||||||
variant="bigSquare"
|
isSelected={isSelected(ProcessorType.BLUR, {
|
||||||
aria-label={tooltipLabel(ProcessorType.BLUR, {
|
blurRadius: BlurRadius.LIGHT,
|
||||||
blurRadius: BlurRadius.NORMAL,
|
})}
|
||||||
})}
|
data-attr="toggle-blur-light"
|
||||||
tooltip={tooltipLabel(ProcessorType.BLUR, {
|
>
|
||||||
blurRadius: BlurRadius.NORMAL,
|
<BlurOn />
|
||||||
})}
|
</ToggleButton>
|
||||||
isDisabled={processorPendingReveal || isDisabled}
|
<ToggleButton
|
||||||
onChange={async () =>
|
variant="bigSquare"
|
||||||
await toggleEffect(ProcessorType.BLUR, {
|
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||||
blurRadius: BlurRadius.NORMAL,
|
blurRadius: BlurRadius.NORMAL,
|
||||||
})
|
})}
|
||||||
}
|
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||||
isSelected={isSelected(ProcessorType.BLUR, {
|
blurRadius: BlurRadius.NORMAL,
|
||||||
blurRadius: BlurRadius.NORMAL,
|
})}
|
||||||
})}
|
isDisabled={processorPendingReveal || isDisabled}
|
||||||
data-attr="toggle-blur-normal"
|
onChange={async () =>
|
||||||
>
|
await toggleEffect(ProcessorType.BLUR, {
|
||||||
<BlurOnStrong />
|
blurRadius: BlurRadius.NORMAL,
|
||||||
</ToggleButton>
|
})
|
||||||
|
}
|
||||||
|
isSelected={isSelected(ProcessorType.BLUR, {
|
||||||
|
blurRadius: BlurRadius.NORMAL,
|
||||||
|
})}
|
||||||
|
data-attr="toggle-blur-normal"
|
||||||
|
>
|
||||||
|
<BlurOnStrong />
|
||||||
|
</ToggleButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
@@ -343,39 +440,37 @@ export const EffectsConfiguration = ({
|
|||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '1.25rem',
|
gap: '1.25rem',
|
||||||
|
paddingBottom: '0.5rem',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{[...Array(8).keys()].map((i) => {
|
{[...Array(8).keys()].map((i) => {
|
||||||
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
|
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
|
||||||
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
|
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
|
||||||
|
const tooltipText = tooltipVirtualBackground(i)
|
||||||
return (
|
return (
|
||||||
<ToggleButton
|
<VisualOnlyTooltip key={i} tooltip={tooltipText}>
|
||||||
key={i}
|
<ToggleButton
|
||||||
variant="bigSquare"
|
variant="bigSquare"
|
||||||
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
|
aria-label={ariaLabelVirtualBackground(i, imagePath)}
|
||||||
imagePath,
|
isDisabled={processorPendingReveal || isDisabled}
|
||||||
})}
|
onChange={async () =>
|
||||||
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
|
await toggleEffect(ProcessorType.VIRTUAL, {
|
||||||
imagePath,
|
imagePath,
|
||||||
})}
|
})
|
||||||
isDisabled={processorPendingReveal || isDisabled}
|
}
|
||||||
onChange={async () =>
|
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||||
await toggleEffect(ProcessorType.VIRTUAL, {
|
|
||||||
imagePath,
|
imagePath,
|
||||||
})
|
})}
|
||||||
}
|
className={css({
|
||||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
bgSize: 'cover',
|
||||||
imagePath,
|
})}
|
||||||
})}
|
style={{
|
||||||
className={css({
|
backgroundImage: `url(${thumbnailPath})`,
|
||||||
bgSize: 'cover',
|
}}
|
||||||
})}
|
data-attr={`toggle-virtual-${i}`}
|
||||||
style={{
|
/>
|
||||||
backgroundImage: `url(${thumbnailPath})`,
|
</VisualOnlyTooltip>
|
||||||
}}
|
|
||||||
data-attr={`toggle-virtual-${i}`}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ const ASPECT_RATIO = 16 / 10
|
|||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface CarouselLayoutProps
|
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
extends React.HTMLAttributes<HTMLMediaElement> {
|
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
/** Place the tiles vertically or horizontally next to each other.
|
/** Place the tiles vertically or horizontally next to each other.
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { PaginationControl } from '../controls/PaginationControl'
|
|||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
Pick<UseParticipantsOptions, 'updateOnlyOn'> {
|
Pick<UseParticipantsOptions, 'updateOnlyOn'> {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ export function useCanPublishTrack(trackSource: TrackSource): boolean {
|
|||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
permissions?.canPublish &&
|
permissions?.canPublish &&
|
||||||
permissions?.canPublishSources?.includes(trackSource)
|
permissions?.canPublishSources?.includes(trackSource)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Shortcut } from '@/features/shortcuts/types'
|
import {
|
||||||
|
getShortcutDescriptorById,
|
||||||
|
ShortcutDescriptor,
|
||||||
|
} from '@/features/shortcuts/catalog'
|
||||||
|
|
||||||
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
|
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
|
||||||
return useMemo<Shortcut | undefined>(() => {
|
return useMemo<ShortcutDescriptor | undefined>(() => {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'audioinput':
|
case 'audioinput':
|
||||||
return {
|
return getShortcutDescriptorById('toggle-microphone')
|
||||||
key: 'd',
|
|
||||||
ctrlKey: true,
|
|
||||||
}
|
|
||||||
case 'videoinput':
|
case 'videoinput':
|
||||||
return {
|
return getShortcutDescriptorById('toggle-camera')
|
||||||
key: 'e',
|
|
||||||
ctrlKey: true,
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
|
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
|
||||||
import React, { useEffect } from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
formatChatMessageLinks,
|
formatChatMessageLinks,
|
||||||
useChat,
|
useChat,
|
||||||
@@ -17,8 +17,7 @@ import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
export interface ChatProps
|
export interface ChatProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>, ChatOptions {}
|
||||||
ChatOptions {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
|
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
|
||||||
@@ -36,34 +35,11 @@ export function Chat({ ...props }: ChatProps) {
|
|||||||
const { isChatOpen } = useSidePanel()
|
const { isChatOpen } = useSidePanel()
|
||||||
const chatSnap = useSnapshot(chatStore)
|
const chatSnap = useSnapshot(chatStore)
|
||||||
|
|
||||||
// Keep track of the element that opened the chat so we can restore focus
|
React.useEffect(() => {
|
||||||
// when the chat panel is closed.
|
if (!isChatOpen) return
|
||||||
const prevIsChatOpenRef = React.useRef(false)
|
requestAnimationFrame(() => {
|
||||||
const chatTriggerRef = React.useRef<HTMLElement | null>(null)
|
inputRef.current?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
useEffect(() => {
|
|
||||||
const wasChatOpen = prevIsChatOpenRef.current
|
|
||||||
const isChatPanelOpen = isChatOpen
|
|
||||||
|
|
||||||
// Chat just opened
|
|
||||||
if (!wasChatOpen && isChatPanelOpen) {
|
|
||||||
chatTriggerRef.current = document.activeElement as HTMLElement | null
|
|
||||||
// Avoid layout "jump" during the side panel slide-in animation.
|
|
||||||
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
inputRef.current?.focus({ preventScroll: true })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Chat just closed
|
|
||||||
if (wasChatOpen && !isChatPanelOpen) {
|
|
||||||
const trigger = chatTriggerRef.current
|
|
||||||
if (trigger && document.contains(trigger)) {
|
|
||||||
trigger.focus({ preventScroll: true })
|
|
||||||
}
|
|
||||||
chatTriggerRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
prevIsChatOpenRef.current = isChatPanelOpen
|
|
||||||
}, [isChatOpen])
|
}, [isChatOpen])
|
||||||
|
|
||||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function DesktopControlBar({
|
|||||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
useRegisterKeyboardShortcut({
|
||||||
shortcut: { key: 'F2' },
|
id: 'focus-toolbar',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
const root = desktopControlBarEl.current
|
const root = desktopControlBarEl.current
|
||||||
if (!root) return
|
if (!root) return
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export function MobileControlBar({
|
|||||||
/>
|
/>
|
||||||
<HandToggle />
|
<HandToggle />
|
||||||
<Button
|
<Button
|
||||||
|
id="room-options-trigger"
|
||||||
square
|
square
|
||||||
variant="primaryDark"
|
variant="primaryDark"
|
||||||
aria-label={t('options.buttonLabel')}
|
aria-label={t('options.buttonLabel')}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const LateralMenu = () => {
|
|||||||
return (
|
return (
|
||||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Button
|
<Button
|
||||||
|
id="controlbar-more-options-trigger"
|
||||||
square
|
square
|
||||||
variant="secondaryDark"
|
variant="secondaryDark"
|
||||||
aria-label={t('controls.moreOptions')}
|
aria-label={t('controls.moreOptions')}
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import {
|
|||||||
isWeb,
|
isWeb,
|
||||||
log,
|
log,
|
||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { RoomEvent, Track } from 'livekit-client'
|
import { Participant, RoomEvent, Track } from 'livekit-client'
|
||||||
import * as React from 'react'
|
import React, { useCallback, useRef, useState, useEffect } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import {
|
import {
|
||||||
ConnectionStateToast,
|
ConnectionStateToast,
|
||||||
FocusLayoutContainer,
|
FocusLayoutContainer,
|
||||||
@@ -16,7 +15,9 @@ import {
|
|||||||
usePinnedTracks,
|
usePinnedTracks,
|
||||||
useTracks,
|
useTracks,
|
||||||
useCreateLayoutContext,
|
useCreateLayoutContext,
|
||||||
|
useRoomContext,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { ControlBar } from './ControlBar/ControlBar'
|
import { ControlBar } from './ControlBar/ControlBar'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
@@ -37,6 +38,8 @@ import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
|||||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
||||||
import { GridLayout } from '../components/layout/GridLayout'
|
import { GridLayout } from '../components/layout/GridLayout'
|
||||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||||
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
const LayoutWrapper = styled(
|
const LayoutWrapper = styled(
|
||||||
'div',
|
'div',
|
||||||
@@ -63,8 +66,7 @@ const LayoutWrapper = styled(
|
|||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export interface VideoConferenceProps
|
export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
/** @alpha */
|
/** @alpha */
|
||||||
SettingsComponent?: React.ComponentType
|
SettingsComponent?: React.ComponentType
|
||||||
}
|
}
|
||||||
@@ -89,7 +91,22 @@ export interface VideoConferenceProps
|
|||||||
*/
|
*/
|
||||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||||
const lastAutoFocusedScreenShareTrack =
|
const lastAutoFocusedScreenShareTrack =
|
||||||
React.useRef<TrackReferenceOrPlaceholder | null>(null)
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
useConnectionObserver()
|
useConnectionObserver()
|
||||||
useVideoResolutionSubscription()
|
useVideoResolutionSubscription()
|
||||||
@@ -115,9 +132,62 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
(track) => !isEqualTrackRef(track, focusTrack)
|
(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 */
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||||
if (
|
if (
|
||||||
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import NoiseSuppressorWorklet from '@timephy/rnnoise-wasm/NoiseSuppressorWorklet
|
|||||||
// and suspend/resume it as needed to manage audio state
|
// and suspend/resume it as needed to manage audio state
|
||||||
let audioContext: AudioContext
|
let audioContext: AudioContext
|
||||||
|
|
||||||
export interface AudioProcessorInterface
|
export interface AudioProcessorInterface extends TrackProcessor<Track.Kind.Audio> {
|
||||||
extends TrackProcessor<Track.Kind.Audio> {
|
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export const getEmojiLabel = (
|
||||||
|
emoji: string,
|
||||||
|
t: ReturnType<typeof useTranslation>['t']
|
||||||
|
) => {
|
||||||
|
const emojiLabels: Record<string, string> = {
|
||||||
|
'thumbs-up': t('emojis.thumbs-up', { defaultValue: 'thumbs up' }),
|
||||||
|
'thumbs-down': t('emojis.thumbs-down', { defaultValue: 'thumbs down' }),
|
||||||
|
'clapping-hands': t('emojis.clapping-hands', {
|
||||||
|
defaultValue: 'clapping hands',
|
||||||
|
}),
|
||||||
|
'red-heart': t('emojis.red-heart', { defaultValue: 'red heart' }),
|
||||||
|
'face-with-tears-of-joy': t('emojis.face-with-tears-of-joy', {
|
||||||
|
defaultValue: 'face with tears of joy',
|
||||||
|
}),
|
||||||
|
'face-with-open-mouth': t('emojis.face-with-open-mouth', {
|
||||||
|
defaultValue: 'surprised face',
|
||||||
|
}),
|
||||||
|
'party-popper': t('emojis.party-popper', { defaultValue: 'party popper' }),
|
||||||
|
'folded-hands': t('emojis.folded-hands', { defaultValue: 'folded hands' }),
|
||||||
|
}
|
||||||
|
return emojiLabels[emoji] ?? emoji
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import { Button } from '@/primitives'
|
|||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
|
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
import { Rating } from '@/features/rooms/components/Rating.tsx'
|
import { Rating } from '@/features/rooms/components/Rating.tsx'
|
||||||
import { useLocation, useSearchParams } from 'wouter'
|
import { useLocation } from 'wouter'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { DisconnectReason } from 'livekit-client'
|
||||||
|
|
||||||
// fixme - duplicated with home, refactor in a proper style
|
// fixme - duplicated with home, refactor in a proper style
|
||||||
const Heading = styled('h1', {
|
const Heading = styled('h1', {
|
||||||
@@ -20,25 +22,40 @@ const Heading = styled('h1', {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
enum DisconnectReasonKey {
|
||||||
|
DuplicateIdentity = 'duplicateIdentity',
|
||||||
|
ParticipantRemoved = 'participantRemoved',
|
||||||
|
}
|
||||||
|
|
||||||
export const FeedbackRoute = () => {
|
export const FeedbackRoute = () => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const [, setLocation] = useLocation()
|
const [, setLocation] = useLocation()
|
||||||
|
|
||||||
const [searchParams] = useSearchParams()
|
const reasonKey = useMemo(() => {
|
||||||
|
const state = window.history.state
|
||||||
|
|
||||||
|
if (!state?.reason) return
|
||||||
|
switch (state.reason) {
|
||||||
|
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||||
|
return DisconnectReasonKey.DuplicateIdentity
|
||||||
|
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||||
|
return DisconnectReasonKey.ParticipantRemoved
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen layout="centered" footer={false}>
|
<Screen layout="centered" footer={false}>
|
||||||
<Center>
|
<Center>
|
||||||
<VStack>
|
<VStack>
|
||||||
<Heading>
|
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
|
||||||
{t(
|
|
||||||
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
|
|
||||||
)}
|
|
||||||
</Heading>
|
|
||||||
<HStack>
|
<HStack>
|
||||||
<Button variant="secondary" onPress={() => window.history.back()}>
|
{showBackButton && (
|
||||||
{t('feedback.back')}
|
<Button variant="secondary" onPress={() => window.history.back()}>
|
||||||
</Button>
|
{t('feedback.back')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="primary" onPress={() => setLocation('/')}>
|
<Button variant="primary" onPress={() => setLocation('/')}>
|
||||||
{t('feedback.home')}
|
{t('feedback.home')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Dialog, type DialogProps } from '@/primitives'
|
|||||||
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
|
import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { text } from '@/primitives/Text.tsx'
|
import { text } from '@/primitives/Text.tsx'
|
||||||
|
import { Icon } from '@/primitives/Icon'
|
||||||
import { Heading } from 'react-aria-components'
|
import { Heading } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
RiSettings3Line,
|
RiSettings3Line,
|
||||||
RiSpeakerLine,
|
RiSpeakerLine,
|
||||||
RiVideoOnLine,
|
RiVideoOnLine,
|
||||||
|
RiEyeLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { AccountTab } from './tabs/AccountTab'
|
import { AccountTab } from './tabs/AccountTab'
|
||||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||||
@@ -21,6 +23,7 @@ import { useRef } from 'react'
|
|||||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||||
|
import AccessibilityTab from './tabs/AccessibilityTab'
|
||||||
|
|
||||||
const tabsStyle = css({
|
const tabsStyle = css({
|
||||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||||
@@ -106,11 +109,16 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
</Tab>
|
</Tab>
|
||||||
{isAdminOrOwner && (
|
{isAdminOrOwner && (
|
||||||
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
|
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
|
||||||
<span className="material-symbols">speech_to_text</span>
|
<Icon type="symbols" name="speech_to_text" />
|
||||||
{isWideScreen &&
|
{isWideScreen &&
|
||||||
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
|
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
|
||||||
</Tab>
|
</Tab>
|
||||||
)}
|
)}
|
||||||
|
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
|
||||||
|
<RiEyeLine />
|
||||||
|
{isWideScreen &&
|
||||||
|
t(`tabs.${SettingsDialogExtendedKey.ACCESSIBILITY}`)}
|
||||||
|
</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
</div>
|
</div>
|
||||||
<div className={tabPanelContainerStyle}>
|
<div className={tabPanelContainerStyle}>
|
||||||
@@ -124,6 +132,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||||
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
||||||
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
||||||
|
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Field, H } from '@/primitives'
|
||||||
|
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { accessibilityStore } from '@/stores/accessibility'
|
||||||
|
|
||||||
|
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
|
||||||
|
|
||||||
|
export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
|
||||||
|
const { t } = useTranslation('settings')
|
||||||
|
const snap = useSnapshot(accessibilityStore)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanel padding={'md'} flex id={id}>
|
||||||
|
<H lvl={2}>{t('tabs.accessibility')}</H>
|
||||||
|
<ul
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<Field
|
||||||
|
type="switch"
|
||||||
|
label={t('accessibility.announceReactions.label')}
|
||||||
|
isSelected={snap.announceReactions}
|
||||||
|
onChange={(value) => {
|
||||||
|
accessibilityStore.announceReactions = value
|
||||||
|
}}
|
||||||
|
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</TabPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccessibilityTab
|
||||||
@@ -18,6 +18,14 @@ export const useTranscriptionLanguage = () => {
|
|||||||
key: RecordingLanguage.ENGLISH,
|
key: RecordingLanguage.ENGLISH,
|
||||||
label: t('language.options.english'),
|
label: t('language.options.english'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: RecordingLanguage.GERMAN,
|
||||||
|
label: t('language.options.german'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: RecordingLanguage.DUTCH,
|
||||||
|
label: t('language.options.dutch'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: RecordingLanguage.AUTOMATIC,
|
key: RecordingLanguage.AUTOMATIC,
|
||||||
label: t('language.options.auto'),
|
label: t('language.options.auto'),
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export enum SettingsDialogExtendedKey {
|
|||||||
GENERAL = 'general',
|
GENERAL = 'general',
|
||||||
NOTIFICATIONS = 'notifications',
|
NOTIFICATIONS = 'notifications',
|
||||||
TRANSCRIPTION = 'transcription',
|
TRANSCRIPTION = 'transcription',
|
||||||
|
ACCESSIBILITY = 'accessibility',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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,4 +1,6 @@
|
|||||||
export type Shortcut = {
|
export type Shortcut = {
|
||||||
key: string
|
key: string
|
||||||
ctrlKey?: boolean
|
ctrlKey?: boolean
|
||||||
|
shiftKey?: boolean
|
||||||
|
altKey?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ export const useKeyboardShortcuts = () => {
|
|||||||
// This approach handles basic shortcuts but isn't comprehensive.
|
// This approach handles basic shortcuts but isn't comprehensive.
|
||||||
// Issues might occur. First draft.
|
// Issues might occur. First draft.
|
||||||
const onKeyDown = async (e: KeyboardEvent) => {
|
const onKeyDown = async (e: KeyboardEvent) => {
|
||||||
const { key, metaKey, ctrlKey } = e
|
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
|
||||||
if (!key) return
|
if (!key) return
|
||||||
const shortcutKey = formatShortcutKey({
|
const shortcutKey = formatShortcutKey({
|
||||||
key,
|
key,
|
||||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||||
|
shiftKey,
|
||||||
|
altKey,
|
||||||
})
|
})
|
||||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||||
if (!shortcut) return
|
if (!shortcut) return
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||||
import { formatShortcutKey } from '@/features/shortcuts/utils'
|
import { formatShortcutKey } from '@/features/shortcuts/utils'
|
||||||
import { Shortcut } from '@/features/shortcuts/types'
|
import { ShortcutId, getShortcutDescriptorById } from './catalog'
|
||||||
|
|
||||||
export type useRegisterKeyboardShortcutProps = {
|
export type useRegisterKeyboardShortcutProps = {
|
||||||
shortcut?: Shortcut
|
id?: ShortcutId
|
||||||
handler: () => Promise<void | boolean | undefined> | void
|
handler: () => Promise<void | boolean | undefined> | void
|
||||||
isDisabled?: boolean
|
isDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useRegisterKeyboardShortcut = ({
|
export const useRegisterKeyboardShortcut = ({
|
||||||
shortcut,
|
id,
|
||||||
handler,
|
handler,
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
}: useRegisterKeyboardShortcutProps) => {
|
}: useRegisterKeyboardShortcutProps) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shortcut) return
|
if (!id) return
|
||||||
const formattedKey = formatShortcutKey(shortcut)
|
const descriptor = getShortcutDescriptorById(id)
|
||||||
|
if (!descriptor?.shortcut) return
|
||||||
|
const formattedKey = formatShortcutKey(descriptor.shortcut)
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||||
} else {
|
} else {
|
||||||
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||||
}
|
}
|
||||||
}, [handler, shortcut, isDisabled])
|
}, [handler, id, isDisabled])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,27 @@ import { Shortcut } from '@/features/shortcuts/types'
|
|||||||
export const CTRL = 'ctrl'
|
export const CTRL = 'ctrl'
|
||||||
|
|
||||||
export const formatShortcutKey = (shortcut: Shortcut) => {
|
export const formatShortcutKey = (shortcut: Shortcut) => {
|
||||||
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
|
const parts = []
|
||||||
return shortcut.key.toUpperCase()
|
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('+')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
|
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
|
||||||
if (!shortcut.key) return
|
if (!shortcut.key) return
|
||||||
let formattedKeyLabel = shortcut.key.toLowerCase()
|
const parts: string[] = []
|
||||||
if (shortcut.ctrlKey) {
|
if (shortcut.ctrlKey) {
|
||||||
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
|
parts.push(isMacintosh() ? '⌘' : 'Ctrl')
|
||||||
}
|
}
|
||||||
|
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})`
|
return `${label} (${formattedKeyLabel})`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useSubtitles } from '../hooks/useSubtitles'
|
import { useSubtitles } from '../hooks/useSubtitles'
|
||||||
import { css, cva } from '@/styled-system/css'
|
import { css, cva } from '@/styled-system/css'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
@@ -13,101 +13,62 @@ export interface TranscriptionSegment {
|
|||||||
id: string
|
id: string
|
||||||
text: string
|
text: string
|
||||||
language: string
|
language: string
|
||||||
startTime: number
|
startTime?: number
|
||||||
endTime: number
|
endTime: number
|
||||||
final: boolean
|
final: boolean
|
||||||
firstReceivedTime: number
|
firstReceivedTime: number
|
||||||
lastReceivedTime: number
|
lastReceivedTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TranscriptionSegmentWithParticipant extends TranscriptionSegment {
|
||||||
|
participant: Participant
|
||||||
|
}
|
||||||
|
|
||||||
export interface TranscriptionRow {
|
export interface TranscriptionRow {
|
||||||
id: string
|
id: string
|
||||||
participant: Participant
|
participant: Participant
|
||||||
segments: TranscriptionSegment[]
|
segments: TranscriptionSegment[]
|
||||||
startTime: number
|
startTime?: number
|
||||||
lastUpdateTime: number
|
lastUpdateTime: number
|
||||||
|
lastReceivedTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const useTranscriptionState = () => {
|
const useTranscriptionState = () => {
|
||||||
const [transcriptionRows, setTranscriptionRows] = useState<
|
const [transcriptionSegments, setTranscriptionSegments] = useState<
|
||||||
TranscriptionRow[]
|
TranscriptionSegmentWithParticipant[]
|
||||||
>([])
|
>([])
|
||||||
const [lastActiveParticipantIdentity, setLastActiveParticipantIdentity] =
|
|
||||||
useState<string | null>(null)
|
|
||||||
|
|
||||||
const updateTranscriptions = (
|
const updateTranscriptionSegments = (
|
||||||
segments: TranscriptionSegment[],
|
segments: TranscriptionSegment[],
|
||||||
participant?: Participant
|
participant?: Participant
|
||||||
) => {
|
) => {
|
||||||
|
console.log(participant, segments)
|
||||||
|
|
||||||
if (!participant || segments.length === 0) return
|
if (!participant || segments.length === 0) return
|
||||||
|
|
||||||
setTranscriptionRows((prevRows) => {
|
if (segments.length > 1) {
|
||||||
const updatedRows = [...prevRows]
|
console.warn('Unexpected error more segments')
|
||||||
const now = Date.now()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const shouldAppendToLastRow =
|
const segment = segments[0]
|
||||||
lastActiveParticipantIdentity === participant.identity &&
|
|
||||||
updatedRows.length > 0
|
|
||||||
|
|
||||||
if (shouldAppendToLastRow) {
|
setTranscriptionSegments((prevSegments) => {
|
||||||
const lastRowIndex = updatedRows.length - 1
|
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
|
||||||
const lastRow = updatedRows[lastRowIndex]
|
if (existingSegmentIds.has(segment.id)) return prevSegments
|
||||||
|
return [
|
||||||
const existingSegmentIds = new Set(lastRow.segments.map((s) => s.id))
|
...prevSegments,
|
||||||
const newSegments = segments.filter(
|
{
|
||||||
(segment) => !existingSegmentIds.has(segment.id)
|
participant: participant,
|
||||||
)
|
...segment,
|
||||||
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 {
|
return {
|
||||||
transcriptionRows,
|
updateTranscriptionSegments,
|
||||||
updateTranscriptions,
|
transcriptionSegments,
|
||||||
clearTranscriptions,
|
|
||||||
updateParticipant,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,24 +153,54 @@ const SubtitlesWrapper = styled(
|
|||||||
export const Subtitles = () => {
|
export const Subtitles = () => {
|
||||||
const { areSubtitlesOpen } = useSubtitles()
|
const { areSubtitlesOpen } = useSubtitles()
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
const { transcriptionRows, updateTranscriptions, updateParticipant } =
|
|
||||||
|
const { transcriptionSegments, updateTranscriptionSegments } =
|
||||||
useTranscriptionState()
|
useTranscriptionState()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!room) return
|
if (!room) return
|
||||||
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions)
|
room.on(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
|
||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions)
|
room.off(RoomEvent.TranscriptionReceived, updateTranscriptionSegments)
|
||||||
}
|
}
|
||||||
}, [room, updateTranscriptions])
|
}, [room, updateTranscriptionSegments])
|
||||||
|
|
||||||
useEffect(() => {
|
const transcriptionRows = useMemo(() => {
|
||||||
if (!room) return
|
if (transcriptionSegments.length === 0) return []
|
||||||
room.on(RoomEvent.ParticipantNameChanged, updateParticipant)
|
|
||||||
return () => {
|
const rows: TranscriptionRow[] = []
|
||||||
room.off(RoomEvent.ParticipantNameChanged, updateParticipant)
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [room, updateParticipant])
|
|
||||||
|
return rows
|
||||||
|
}, [transcriptionSegments])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
|
<SubtitlesWrapper areOpen={areSubtitlesOpen}>
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { useSnapshot } from 'valtio'
|
|||||||
import { layoutStore } from '@/stores/layout'
|
import { layoutStore } from '@/stores/layout'
|
||||||
import { useStartSubtitle } from '../api/startSubtitle'
|
import { useStartSubtitle } from '../api/startSubtitle'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
|
|
||||||
export const useSubtitles = () => {
|
export const useSubtitles = () => {
|
||||||
const layoutSnap = useSnapshot(layoutStore)
|
const layoutSnap = useSnapshot(layoutStore)
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
const apiRoomData = useRoomData()
|
const apiRoomData = useRoomData()
|
||||||
const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle()
|
const { mutateAsync: startSubtitleRoom, isPending } = useStartSubtitle()
|
||||||
|
|
||||||
@@ -20,6 +24,18 @@ export const useSubtitles = () => {
|
|||||||
layoutStore.showSubtitles = !layoutSnap.showSubtitles
|
layoutStore.showSubtitles = !layoutSnap.showSubtitles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!room) return
|
||||||
|
|
||||||
|
const closeSubtitles = () => {
|
||||||
|
layoutStore.showSubtitles = false
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.Disconnected, closeSubtitles)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.Disconnected, closeSubtitles)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
areSubtitlesOpen: layoutSnap.showSubtitles,
|
areSubtitlesOpen: layoutSnap.showSubtitles,
|
||||||
toggleSubtitles,
|
toggleSubtitles,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export type RestoreFocusOptions = {
|
||||||
|
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
|
||||||
|
onOpened?: () => void
|
||||||
|
onClosed?: () => void
|
||||||
|
restoreFocusRaf?: boolean
|
||||||
|
preventScroll?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture the element that opened a panel/menu (on open transition) and restore focus to it
|
||||||
|
* when the panel/menu closes.
|
||||||
|
*/
|
||||||
|
export function useRestoreFocus(
|
||||||
|
isOpen: boolean,
|
||||||
|
options: RestoreFocusOptions = {}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
resolveTrigger,
|
||||||
|
onOpened,
|
||||||
|
onClosed,
|
||||||
|
restoreFocusRaf = false,
|
||||||
|
preventScroll = true,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const prevIsOpenRef = useRef(false)
|
||||||
|
const triggerRef = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wasOpen = prevIsOpenRef.current
|
||||||
|
|
||||||
|
// Just opened
|
||||||
|
if (!wasOpen && isOpen) {
|
||||||
|
const activeEl = document.activeElement as HTMLElement | null
|
||||||
|
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
|
||||||
|
onOpened?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just closed
|
||||||
|
if (wasOpen && !isOpen) {
|
||||||
|
const trigger = triggerRef.current
|
||||||
|
if (trigger && document.contains(trigger)) {
|
||||||
|
const focus = () => trigger.focus({ preventScroll })
|
||||||
|
if (restoreFocusRaf) requestAnimationFrame(focus)
|
||||||
|
else focus()
|
||||||
|
}
|
||||||
|
triggerRef.current = null
|
||||||
|
onClosed?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
prevIsOpenRef.current = isOpen
|
||||||
|
}, [
|
||||||
|
isOpen,
|
||||||
|
onClosed,
|
||||||
|
onOpened,
|
||||||
|
preventScroll,
|
||||||
|
resolveTrigger,
|
||||||
|
restoreFocusRaf,
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { FeedbackBanner } from '@/components/FeedbackBanner'
|
|||||||
import { Menu } from '@/primitives/Menu'
|
import { Menu } from '@/primitives/Menu'
|
||||||
import { MenuList } from '@/primitives/MenuList'
|
import { MenuList } from '@/primitives/MenuList'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||||
|
|
||||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||||
|
|
||||||
@@ -90,6 +91,11 @@ export const Header = () => {
|
|||||||
const isTermsOfService = useMatchesRoute('termsOfService')
|
const isTermsOfService = useMatchesRoute('termsOfService')
|
||||||
const isRoom = useMatchesRoute('room')
|
const isRoom = useMatchesRoute('room')
|
||||||
const { user, isLoggedIn, logout } = useUser()
|
const { user, isLoggedIn, logout } = useUser()
|
||||||
|
const userLabel = user?.full_name || user?.email
|
||||||
|
const loggedInTooltip = t('loggedInUserTooltip')
|
||||||
|
const loggedInAriaLabel = userLabel
|
||||||
|
? `${loggedInTooltip} ${userLabel}`
|
||||||
|
: loggedInTooltip
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -153,23 +159,24 @@ export const Header = () => {
|
|||||||
)}
|
)}
|
||||||
{!!user && (
|
{!!user && (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Button
|
<Button size="sm" variant="secondaryText">
|
||||||
size="sm"
|
<VisualOnlyTooltip
|
||||||
variant="secondaryText"
|
tooltip={loggedInTooltip}
|
||||||
tooltip={t('loggedInUserTooltip')}
|
ariaLabel={loggedInAriaLabel}
|
||||||
tooltipType="delayed"
|
tooltipPosition="bottom"
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={css({
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
maxWidth: '350px',
|
|
||||||
display: { base: 'none', xsm: 'block' },
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{user?.full_name || user?.email}
|
<span
|
||||||
</span>
|
className={css({
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: '350px',
|
||||||
|
display: { base: 'none', xsm: 'block' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{user?.full_name || user?.email}
|
||||||
|
</span>
|
||||||
|
</VisualOnlyTooltip>
|
||||||
</Button>
|
</Button>
|
||||||
<MenuList
|
<MenuList
|
||||||
variant={'light'}
|
variant={'light'}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Header } from './Header'
|
|||||||
import { layoutStore } from '@/stores/layout'
|
import { layoutStore } from '@/stores/layout'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { Footer } from '@/layout/Footer'
|
import { Footer } from '@/layout/Footer'
|
||||||
|
import { ScreenReaderAnnouncer } from '@/primitives'
|
||||||
|
|
||||||
export type Layout = 'fullpage' | 'centered'
|
export type Layout = 'fullpage' | 'centered'
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
<ScreenReaderAnnouncer />
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,18 +44,15 @@
|
|||||||
},
|
},
|
||||||
"slide1": {
|
"slide1": {
|
||||||
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!",
|
"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": {
|
"slide2": {
|
||||||
"title": "Gruppenanrufe ohne Einschränkungen",
|
"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": {
|
"slide3": {
|
||||||
"title": "Verwandeln Sie Ihre Meetings mit KI",
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user