mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 21:01:55 +00:00
Compare commits
97 Commits
refactoring
...
v1.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
| aaf21e97e8 | |||
| bd3a26a2af | |||
| 4d222e4ab4 | |||
| b80c46da54 | |||
| 451be40bb7 | |||
| 45c5a443fb | |||
| 34f9dea73f | |||
| f0fda145d9 | |||
| d12ced352a | |||
| 497b45f2ca | |||
| 52fbd56666 | |||
| 170763a1f7 | |||
| 037166fb21 | |||
| 6374e136d8 | |||
| 3ccb2d4dd8 | |||
| 5d7a54e809 | |||
| 07af7a85ff | |||
| 6180ac4e4f | |||
| a30b573d36 | |||
| 83b95c5520 | |||
| 0c9b83c793 | |||
| 812d80c4f2 | |||
| 34212be6e2 | |||
| d8ccd02bb2 | |||
| 08aa63ecb2 | |||
| bbc8f61221 | |||
| 6b656eefd7 | |||
| 264f267ac3 | |||
| 4bf3ba4c48 | |||
| 4fdc2eee11 | |||
| 19c2a378e7 | |||
| 5a70604f01 | |||
| 7e422e5846 | |||
| d915b93caa | |||
| 9d9ec794aa | |||
| 570d57d5f5 | |||
| 7469ccfdf1 | |||
| 0d3bd2727e | |||
| 660b022eb1 | |||
| a28b611ecc | |||
| dfa6092c72 | |||
| 7d8c166c7f | |||
| f7dd1f8fd7 | |||
| 416411b843 | |||
| 45e0665cf0 | |||
| a3eabf8f66 | |||
| 7c81947681 | |||
| 2424817523 | |||
| 15133f9d6b | |||
| bea1f18ab8 | |||
| d5a614d2b5 | |||
| 108db2e3e5 | |||
| 73496406e8 | |||
| c5c96369c8 | |||
| e9f90e95b1 | |||
| f57fbf2d35 | |||
| 920f4558fc | |||
| 9df901b9d6 | |||
| c09c440631 | |||
| cd7ce77074 | |||
| 6d3c26419d | |||
| 9dbc38984e | |||
| 1bd5a294e4 | |||
| 4d98ed4977 | |||
| bf32c073c6 | |||
| 4f2c4bfaf9 | |||
| dacf705329 | |||
| 8296738347 | |||
| 04be495351 | |||
| 43185605eb | |||
| cf3fb208e2 | |||
| 4ca230eb12 | |||
| 4b5e0cb2a3 | |||
| 45f374610f | |||
| b419a2bfd2 | |||
| ee8d96bee7 | |||
| c65ff2d75d | |||
| ea1c90d8ca | |||
| 3e963e3e6d | |||
| 82769128a1 | |||
| 05f4ce6b2e | |||
| 508984ecfa | |||
| a05507f73d | |||
| a27def119c | |||
| fce94f38ce | |||
| 83bd9c5ce3 | |||
| bfbfade99a | |||
| 16daf7b8d3 | |||
| fcad79d662 | |||
| 2424ce17ec | |||
| 4715905334 | |||
| 7c05aedcfe | |||
| 7347fc7c86 | |||
| ada7d9a666 | |||
| 0cb3fb8e3c | |||
| dcb788b57b | |||
| 73bcb9d598 |
@@ -12,6 +12,9 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'main'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: 1001:127
|
DOCKER_USER: 1001:127
|
||||||
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||||
@@ -20,6 +23,8 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push-backend:
|
build-and-push-backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
@@ -43,12 +48,12 @@ 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
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '--target backend-production -f Dockerfile'
|
docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -63,6 +68,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-frontend-generic:
|
build-and-push-frontend-generic:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
@@ -86,12 +93,12 @@ 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
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -107,6 +114,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-frontend-dinum:
|
build-and-push-frontend-dinum:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
@@ -130,12 +139,12 @@ 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
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -151,6 +160,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-summary:
|
build-and-push-summary:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
@@ -174,13 +185,13 @@ 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
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# continue-on-error: true
|
continue-on-error: true
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f src/summary/Dockerfile --target production'
|
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||||
docker-context: './src/summary'
|
docker-context: './src/summary'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
@@ -197,6 +208,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-agents:
|
build-and-push-agents:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
@@ -220,14 +233,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
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# continue-on-error: true
|
continue-on-error: true
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f src/agents/Dockerfile --target production'
|
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||||
# docker-context: './src/agents'
|
docker-context: './src/agents'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -242,6 +255,8 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
notify-argocd:
|
notify-argocd:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
needs:
|
needs:
|
||||||
- build-and-push-frontend-generic
|
- build-and-push-frontend-generic
|
||||||
- build-and-push-frontend-dinum
|
- build-and-push-frontend-dinum
|
||||||
|
|||||||
@@ -223,8 +223,6 @@ jobs:
|
|||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
REDIS_URL: redis://localhost:6379/1
|
REDIS_URL: redis://localhost:6379/1
|
||||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
LIVEKIT_API_SECRET: secret
|
|
||||||
LIVEKIT_API_KEY: devkey
|
|
||||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
@@ -297,6 +295,45 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: uv run pytest -n 2
|
run: uv run pytest -n 2
|
||||||
|
|
||||||
|
test-summary:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/summary
|
||||||
|
|
||||||
|
env:
|
||||||
|
V1_TENANT_ID: 'test-tenant'
|
||||||
|
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||||
|
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||||
|
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||||
|
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY: "password"
|
||||||
|
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
||||||
|
WHISPERX_ASR_MODEL: "large-v2"
|
||||||
|
WHISPERX_API_KEY: "test-whisperx-secret"
|
||||||
|
WHISPERX_DEFAULT_LANGUAGE: "fr"
|
||||||
|
LLM_BASE_URL: "https://configure-your-url.com"
|
||||||
|
LLM_API_KEY: "test-llm-secret"
|
||||||
|
LLM_MODEL: "test-llm-model"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install development dependencies
|
||||||
|
run: pip install --user .[dev]
|
||||||
|
|
||||||
|
- name: Run summary tests
|
||||||
|
run: ~/.local/bin/pytest
|
||||||
|
|
||||||
lint-front:
|
lint-front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
+78
-2
@@ -8,6 +8,76 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.14.0] - 2026-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 🔒️(helm) Add pod and container securityContext #1197
|
||||||
|
- ✨(summary) add routes v2 for async STT and summary tasks #1171
|
||||||
|
- ✅(backend) add unit tests for JwtTokenService #1232
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
|
||||||
|
- ⬆️(frontend) bump hono from 4.12.8 to 4.12.12 in /src/frontend
|
||||||
|
- ⬆️(backend) bump pygments from 2.19.2 to 2.20.0 in /src/backend
|
||||||
|
- ♻️(backend) use Authorization header for LiveKit token authentication
|
||||||
|
- 🥅(backend) refine Twirp error handling for participant operations
|
||||||
|
- ✨(summary) allow more file extensions #1265
|
||||||
|
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
|
||||||
|
- ♿️(frontend) set an explicit document title on recording download page #1261
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY]
|
||||||
|
- ⬆️(dependencies) update vite to v7.3.2 [SECURITY]
|
||||||
|
- ⬆️(dependencies) update django to v5.2.13 [SECURITY]
|
||||||
|
- 🔒(backend) rely on backend to allow participant update their metadata
|
||||||
|
- 🐛(summary) fix failure webhook notification #1233
|
||||||
|
- 🐛(summary) relax whisperX payload format #1233
|
||||||
|
- ⬆️(backend) upgrade dependencies to fix Pillow CVE-2026-40192
|
||||||
|
- ⬆️(frontend) upgrade frontend image to Alpine 3.23 to address CVEs
|
||||||
|
|
||||||
|
## [1.13.0] - 2026-03-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⬆️(dependencies) update python dependencies
|
||||||
|
- ♿️(frontend) add explicit region for call controls #1216
|
||||||
|
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
|
||||||
|
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
|
||||||
|
- 🐛(backend) fix regression in update-participant endpoint #1204
|
||||||
|
|
||||||
|
## [1.12.0] - 2026-03-24
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
|
||||||
|
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
|
||||||
|
- ♿️(frontend) fix more tools heading hierarchy #1181
|
||||||
|
- ♿️(fronted) improve button descriptions for More tools actions #1184
|
||||||
|
- 💄(spinner) enforce spinner height #1183
|
||||||
|
- 💄(custom-background) add upload indicator with preview #1183
|
||||||
|
- ♿️(backend) improve logo accessibility in recording email notification #1092
|
||||||
|
- ♿️(summary) improve accessibility of transcription download link #1187
|
||||||
|
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
|
||||||
|
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
|
||||||
|
- ⬆️️️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
|
||||||
|
- ⬆️️️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
|
||||||
|
- ⬆️️️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) disable personal custom background while deleting #1183
|
||||||
|
- 🐛(frontend) auto-select new custom background when not logged in #1183
|
||||||
|
- 🐛(frontend) fix device selection not applying during conference #1156
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-03-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(helm) support celery with our Django backend #1124
|
- ✨(helm) support celery with our Django backend #1124
|
||||||
@@ -15,19 +85,24 @@ and this project adheres to
|
|||||||
- ✨(backend) add authenticated user rate throttling on request-entry #1129
|
- ✨(backend) add authenticated user rate throttling on request-entry #1129
|
||||||
- ✨(backend) expose `is_active` field for Application in Django admin #1133
|
- ✨(backend) expose `is_active` field for Application in Django admin #1133
|
||||||
- ✨(file-upload) disable by default & limit count by user #1141
|
- ✨(file-upload) disable by default & limit count by user #1141
|
||||||
|
- ✨(frontend) custom background #1067
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ♿️(frontend) Caption text size setting for accessibility #1062
|
- ♿️(frontend) Caption text size setting for accessibility #1062
|
||||||
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
|
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
|
||||||
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
|
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
|
||||||
- ♿(frontend) improve chat toast a11y for screen readers #1109
|
- ♿️(frontend) improve chat toast a11y for screen readers #1109
|
||||||
- ♿(frontend) improve ui and aria labels for help article links #1108
|
- ♿️(frontend) improve ui and aria labels for help article links #1108
|
||||||
- 🌐(frontend) improve German translation #1125
|
- 🌐(frontend) improve German translation #1125
|
||||||
- 🔨(python-env) migrate meet main app to UV #1120
|
- 🔨(python-env) migrate meet main app to UV #1120
|
||||||
- ♻️(backend) align Application model field with `is_active` convention #1133
|
- ♻️(backend) align Application model field with `is_active` convention #1133
|
||||||
- 🔐(backend) avoids revealing the inactive status of an application #1135
|
- 🔐(backend) avoids revealing the inactive status of an application #1135
|
||||||
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
|
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
|
||||||
|
- 🔒️(backend) avoid information exposure through exception messages #1144
|
||||||
|
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
|
||||||
|
- 📌(agents) unpin OpenSSL and related dependencies #1167
|
||||||
|
- ♿️(frontend) add caption font and background color customization #1122
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -35,6 +110,7 @@ and this project adheres to
|
|||||||
- 🩹(backend) add page_size to pagination for room endpoints #1131
|
- 🩹(backend) add page_size to pagination for room endpoints #1131
|
||||||
- 🐛(backend) refactor lobby throttling to use participant id #1129
|
- 🐛(backend) refactor lobby throttling to use participant id #1129
|
||||||
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
|
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
|
||||||
|
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
|
||||||
|
|
||||||
## [1.10.0] - 2026-03-05
|
## [1.10.0] - 2026-03-05
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ create-env-files: \
|
|||||||
env.d/development/crowdin \
|
env.d/development/crowdin \
|
||||||
env.d/development/postgresql \
|
env.d/development/postgresql \
|
||||||
env.d/development/kc_postgresql \
|
env.d/development/kc_postgresql \
|
||||||
env.d/development/summary
|
env.d/development/summary \
|
||||||
|
env.d/development/kube-secret \
|
||||||
|
env.d/development/multi_user_transcriber
|
||||||
.PHONY: create-env-files
|
.PHONY: create-env-files
|
||||||
|
|
||||||
bootstrap: ## Prepare Docker images for the project
|
bootstrap: ## Prepare Docker images for the project
|
||||||
@@ -94,6 +96,7 @@ bootstrap: \
|
|||||||
build: ## build the project containers
|
build: ## build the project containers
|
||||||
@$(MAKE) build-backend
|
@$(MAKE) build-backend
|
||||||
@$(MAKE) build-frontend
|
@$(MAKE) build-frontend
|
||||||
|
@$(MAKE) build-agents
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
||||||
build-backend: ## build the app-dev container
|
build-backend: ## build the app-dev container
|
||||||
@@ -105,6 +108,10 @@ build-frontend: ## build the frontend container
|
|||||||
@$(COMPOSE) build frontend
|
@$(COMPOSE) build frontend
|
||||||
.PHONY: build-frontend
|
.PHONY: build-frontend
|
||||||
|
|
||||||
|
build-agents: ## build the multi-user-transcriber agent container
|
||||||
|
@$(COMPOSE) build multi-user-transcriber
|
||||||
|
.PHONY: build-agents
|
||||||
|
|
||||||
down: ## stop and remove containers, networks, images, and volumes
|
down: ## stop and remove containers, networks, images, and volumes
|
||||||
@$(COMPOSE) down
|
@$(COMPOSE) down
|
||||||
.PHONY: down
|
.PHONY: down
|
||||||
@@ -125,10 +132,15 @@ run-summary: ## start only the summary application and all needed services
|
|||||||
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
||||||
.PHONY: run-summary
|
.PHONY: run-summary
|
||||||
|
|
||||||
|
run-agents: ## start the multi-user-transcriber agent
|
||||||
|
@$(COMPOSE) up --force-recreate -d multi-user-transcriber
|
||||||
|
.PHONY: run-agents
|
||||||
|
|
||||||
run:
|
run:
|
||||||
run: ## start the wsgi (production) and development server
|
run: ## start the wsgi (production) and development server
|
||||||
@$(MAKE) run-backend
|
@$(MAKE) run-backend
|
||||||
@$(MAKE) run-summary
|
@$(MAKE) run-summary
|
||||||
|
@$(MAKE) run-agents
|
||||||
@$(COMPOSE) up --force-recreate -d frontend
|
@$(COMPOSE) up --force-recreate -d frontend
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
|
|
||||||
@@ -191,6 +203,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
|||||||
|
|
||||||
test: ## run project tests
|
test: ## run project tests
|
||||||
@$(MAKE) test-back-parallel
|
@$(MAKE) test-back-parallel
|
||||||
|
@$(MAKE) test-summary
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-back: ## run back-end tests
|
test-back: ## run back-end tests
|
||||||
@@ -203,6 +216,11 @@ test-back-parallel: ## run all back-end tests in parallel
|
|||||||
bin/pytest -n auto $${args:-${1}}
|
bin/pytest -n auto $${args:-${1}}
|
||||||
.PHONY: test-back-parallel
|
.PHONY: test-back-parallel
|
||||||
|
|
||||||
|
test-summary: ## run summary tests
|
||||||
|
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
|
bin/pytest-summary $${args:-${1}}
|
||||||
|
.PHONY: test-summary
|
||||||
|
|
||||||
makemigrations: ## run django makemigrations for the Meet project.
|
makemigrations: ## run django makemigrations for the Meet project.
|
||||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||||
@$(COMPOSE) up -d postgresql
|
@$(COMPOSE) up -d postgresql
|
||||||
@@ -259,6 +277,12 @@ env.d/development/kc_postgresql:
|
|||||||
env.d/development/summary:
|
env.d/development/summary:
|
||||||
cp -n env.d/development/summary.dist env.d/development/summary
|
cp -n env.d/development/summary.dist env.d/development/summary
|
||||||
|
|
||||||
|
env.d/development/kube-secret:
|
||||||
|
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
|
||||||
|
|
||||||
|
env.d/development/multi_user_transcriber:
|
||||||
|
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
||||||
|
|
||||||
# -- Internationalization
|
# -- Internationalization
|
||||||
|
|
||||||
env.d/development/crowdin:
|
env.d/development/crowdin:
|
||||||
@@ -346,15 +370,9 @@ frontend-i18n-generate: \
|
|||||||
|
|
||||||
# -- K8S
|
# -- K8S
|
||||||
build-k8s-cluster: ## build the kubernetes cluster using kind
|
build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||||
./bin/start-kind.sh
|
build-k8s-cluster: \
|
||||||
.PHONY: build-k8s-cluster
|
env.d/development/kube-secret \
|
||||||
|
./bin/start-kind.sh
|
||||||
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
|
||||||
./bin/install-external-secrets.sh
|
|
||||||
.PHONY: build-k8s-cluster
|
|
||||||
|
|
||||||
start-tilt: ## start the kubernetes cluster using kind
|
|
||||||
tilt up --namespace=meet -f ./bin/Tiltfile
|
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||||
|
|||||||
+9
-2
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
|||||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||||
namespace_create('meet')
|
namespace_create('meet')
|
||||||
|
|
||||||
DEV_ENV = os.getenv('DEV_ENV', 'dev')
|
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
||||||
|
|
||||||
if DEV_ENV == 'dev-dinum':
|
if DEV_ENV == 'dev-dinum':
|
||||||
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
||||||
@@ -95,12 +95,19 @@ docker_build(
|
|||||||
)
|
)
|
||||||
clean_old_images('localhost:5001/meet-livekit')
|
clean_old_images('localhost:5001/meet-livekit')
|
||||||
|
|
||||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
load('ext://secret', 'secret_yaml_generic')
|
||||||
|
k8s_yaml(secret_yaml_generic(
|
||||||
|
name="secret-dev",
|
||||||
|
from_env_file="../env.d/development/kube-secret"
|
||||||
|
))
|
||||||
|
|
||||||
|
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
||||||
|
|
||||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||||
|
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||||
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
||||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||||
|
|||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||||
|
|
||||||
|
_dc_run \
|
||||||
|
app-summary-dev \
|
||||||
|
python -m pytest "$@"
|
||||||
@@ -307,6 +307,14 @@ services:
|
|||||||
- action: rebuild
|
- action: rebuild
|
||||||
path: ./src/summary
|
path: ./src/summary
|
||||||
|
|
||||||
|
multi-user-transcriber:
|
||||||
|
build:
|
||||||
|
context: ./src/agents
|
||||||
|
env_file:
|
||||||
|
- env.d/development/multi_user_transcriber
|
||||||
|
volumes:
|
||||||
|
- ./src/agents:/app
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
resource-server:
|
resource-server:
|
||||||
|
|||||||
@@ -39,15 +39,16 @@ COPY ./docker/dinum-frontend/fonts/ \
|
|||||||
./dist/assets/fonts/
|
./dist/assets/fonts/
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk update && apk upgrade libssl3 \
|
|
||||||
libcrypto3 \
|
# Security patches for known CVEs
|
||||||
libxml2>=2.12.7-r2 \
|
RUN apk update && apk upgrade \
|
||||||
libxslt>=1.1.39-r2 \
|
musl \
|
||||||
libexpat>=2.7.2-r0 \
|
musl-utils \
|
||||||
libpng>=1.6.53-r0
|
zlib>=1.3.2-r0 \
|
||||||
|
&& apk del curl
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
|
|||||||
@@ -61,11 +61,10 @@ services:
|
|||||||
`docker compose up -d`
|
`docker compose up -d`
|
||||||
```
|
```
|
||||||
|
|
||||||
Your keycloak instance is now available on https://doc.yourdomain.tld
|
Your keycloak instance is now available on https://id.yourdomain.tld
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
|
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
|
||||||
```
|
|
||||||
|
|
||||||
## Creating an OIDC Client for Meet Application
|
## Creating an OIDC Client for Meet Application
|
||||||
|
|
||||||
@@ -76,7 +75,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
|
|||||||
3. Enter the name of the realm - `meet`.
|
3. Enter the name of the realm - `meet`.
|
||||||
4. Click "Create".
|
4. Click "Create".
|
||||||
|
|
||||||
#### Step 2: Create a New Client
|
### Step 2: Create a New Client
|
||||||
|
|
||||||
1. Navigate to the "Clients" tab.
|
1. Navigate to the "Clients" tab.
|
||||||
2. Click on the "Create client" button.
|
2. Click on the "Create client" button.
|
||||||
@@ -86,7 +85,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
|
|||||||
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
||||||
1. Click "Save".
|
1. Click "Save".
|
||||||
|
|
||||||
#### 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.
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ paths:
|
|||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/ForbiddenError'
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
|
||||||
|
/rooms/:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Rooms
|
- Rooms
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ paths:
|
|||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/ForbiddenError'
|
$ref: '#/components/responses/ForbiddenError'
|
||||||
|
|
||||||
|
/rooms/:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Rooms
|
- Rooms
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
|||||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||||
AWS_S3_ACCESS_KEY_ID=meet
|
AWS_S3_ACCESS_KEY_ID=meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY=password
|
AWS_S3_SECRET_ACCESS_KEY=password
|
||||||
MEDIA_BASE_URL=http://localhost:8083
|
MEDIA_BASE_URL=http://localhost:3000
|
||||||
FILE_UPLOAD_ENABLED=True
|
FILE_UPLOAD_ENABLED=True
|
||||||
|
|
||||||
# OIDC
|
# OIDC
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
WHISPERX_BASE_URL=https://configure-your-url.com
|
||||||
|
WHISPERX_API_KEY=<key>
|
||||||
|
LLM_BASE_URL=https://configure-your-url.com
|
||||||
|
LLM_API_KEY=<key>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
LIVEKIT_URL=ws://livekit:7880
|
||||||
|
LIVEKIT_API_KEY=devkey
|
||||||
|
LIVEKIT_API_SECRET=secret
|
||||||
|
|
||||||
|
STT_PROVIDER=kyutai
|
||||||
|
ENABLE_SILERO_VAD=False
|
||||||
|
|
||||||
|
KYUTAI_STT_BASE_URL=
|
||||||
|
KYUTAI_API_KEY=
|
||||||
@@ -36,6 +36,12 @@
|
|||||||
"matchPackageNames": ["django"],
|
"matchPackageNames": ["django"],
|
||||||
"allowedVersions": "<6.0.0"
|
"allowedVersions": "<6.0.0"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"groupName": "allowed brevo versions",
|
||||||
|
"matchManagers": ["pep621"],
|
||||||
|
"matchPackageNames": ["brevo-python"],
|
||||||
|
"allowedVersions": "<3.0.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"groupName": "ignored js dependencies",
|
"groupName": "ignored js dependencies",
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ FROM python:3.13-slim AS base
|
|||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libgobject-2.0-0 \
|
libgobject-2.0-0 \
|
||||||
"openssl=3.5.4-1~deb13u2" \
|
|
||||||
"libssl3t64=3.5.4-1~deb13u2" \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.10.0"
|
version = "1.14.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.3.10",
|
"livekit-agents==1.4.5",
|
||||||
"livekit-plugins-deepgram==1.3.10",
|
"livekit-plugins-deepgram==1.4.5",
|
||||||
"livekit-plugins-silero==1.3.10",
|
"livekit-plugins-silero==1.4.5",
|
||||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||||
"python-dotenv==1.2.1",
|
"python-dotenv==1.2.2",
|
||||||
"protobuf==6.33.5"
|
"protobuf==6.33.5"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.14.4",
|
"ruff==0.15.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -303,6 +303,9 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
||||||
|
|
||||||
|
|
||||||
class ParticipantPermission(BaseModel):
|
class ParticipantPermission(BaseModel):
|
||||||
"""Mirror the LiveKit ParticipantPermission protobuf.
|
"""Mirror the LiveKit ParticipantPermission protobuf.
|
||||||
|
|
||||||
@@ -313,9 +316,7 @@ class ParticipantPermission(BaseModel):
|
|||||||
can_subscribe: bool | None = None
|
can_subscribe: bool | None = None
|
||||||
can_publish: bool | None = None
|
can_publish: bool | None = None
|
||||||
can_publish_data: bool | None = None
|
can_publish_data: bool | None = None
|
||||||
can_publish_sources: list[int] = Field(
|
can_publish_sources: list[TrackSource] = Field(default_factory=list)
|
||||||
default_factory=list
|
|
||||||
) # TrackSource enum values
|
|
||||||
hidden: bool | None = None
|
hidden: bool | None = None
|
||||||
recorder: bool | None = None
|
recorder: bool | None = None
|
||||||
can_update_metadata: bool | None = None
|
can_update_metadata: bool | None = None
|
||||||
@@ -366,14 +367,6 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
f"Setting the following participant permissions is not allowed: "
|
f"Setting the following participant permissions is not allowed: "
|
||||||
f"{', '.join(suspicious_fields)}."
|
f"{', '.join(suspicious_fields)}."
|
||||||
)
|
)
|
||||||
if permission.can_subscribe_metrics is not None:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
{
|
|
||||||
"permission": {
|
|
||||||
"can_subscribe_metrics": "This permission is not implemented."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return permission
|
return permission
|
||||||
|
|
||||||
@@ -533,3 +526,15 @@ class CreateFileSerializer(ListFileSerializer):
|
|||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
raise NotImplementedError("Update method can not be used.")
|
raise NotImplementedError("Update method can not be used.")
|
||||||
|
|
||||||
|
|
||||||
|
class RaiseHandSerializer(BaseValidationOnlySerializer):
|
||||||
|
"""Serializer for raising or lowering a participant's hand in a room."""
|
||||||
|
|
||||||
|
raised = serializers.BooleanField()
|
||||||
|
|
||||||
|
|
||||||
|
class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
||||||
|
"""Serializer for renaming a participant in a room."""
|
||||||
|
|
||||||
|
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from django.core.files.storage import default_storage
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ from core.services.lobby import (
|
|||||||
LobbyService,
|
LobbyService,
|
||||||
)
|
)
|
||||||
from core.services.participants_management import (
|
from core.services.participants_management import (
|
||||||
|
ParticipantNotFoundException,
|
||||||
ParticipantsManagement,
|
ParticipantsManagement,
|
||||||
ParticipantsManagementException,
|
ParticipantsManagementException,
|
||||||
)
|
)
|
||||||
@@ -488,9 +490,7 @@ class RoomViewSet(
|
|||||||
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
|
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
return drf_response.Response(
|
return drf_response.Response({"status": "error"}, status=status_code)
|
||||||
{"status": "error", "message": str(e)}, status=status_code
|
|
||||||
)
|
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=False,
|
detail=False,
|
||||||
@@ -600,6 +600,11 @@ class RoomViewSet(
|
|||||||
identity=str(serializer.validated_data["participant_identity"]),
|
identity=str(serializer.validated_data["participant_identity"]),
|
||||||
track_sid=serializer.validated_data["track_sid"],
|
track_sid=serializer.validated_data["track_sid"],
|
||||||
)
|
)
|
||||||
|
except ParticipantNotFoundException:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Participant not found"},
|
||||||
|
status=drf_status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"error": "Failed to mute participant"},
|
{"error": "Failed to mute participant"},
|
||||||
@@ -638,6 +643,11 @@ class RoomViewSet(
|
|||||||
permission=permission.model_dump() if permission else None,
|
permission=permission.model_dump() if permission else None,
|
||||||
name=serializer.validated_data.get("name"),
|
name=serializer.validated_data.get("name"),
|
||||||
)
|
)
|
||||||
|
except ParticipantNotFoundException:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Participant not found"},
|
||||||
|
status=drf_status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"error": "Failed to update participant"},
|
{"error": "Failed to update participant"},
|
||||||
@@ -670,6 +680,11 @@ class RoomViewSet(
|
|||||||
room_name=str(room.pk),
|
room_name=str(room.pk),
|
||||||
identity=str(serializer.validated_data["participant_identity"]),
|
identity=str(serializer.validated_data["participant_identity"]),
|
||||||
)
|
)
|
||||||
|
except ParticipantNotFoundException:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Participant not found"},
|
||||||
|
status=drf_status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"error": "Failed to remove participant"},
|
{"error": "Failed to remove participant"},
|
||||||
@@ -680,6 +695,92 @@ class RoomViewSet(
|
|||||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=True,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="toggle-hand",
|
||||||
|
url_name="toggle-hand",
|
||||||
|
permission_classes=[permissions.HasLiveKitRoomAccess],
|
||||||
|
authentication_classes=[LiveKitTokenAuthentication],
|
||||||
|
)
|
||||||
|
def toggle_hand(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
|
"""Raise or lower the current participant's hand in the room."""
|
||||||
|
room = self.get_object()
|
||||||
|
|
||||||
|
serializer = serializers.RaiseHandSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
identity = request.auth.identity
|
||||||
|
|
||||||
|
# LiveKit uses the handRaisedAt participant attribute to signal hand state.
|
||||||
|
# An empty string means the hand is lowered; a non-empty ISO 8601 timestamp
|
||||||
|
# means the hand is raised. The timestamp is used by clients to determine
|
||||||
|
# the order in which participants raised their hands.
|
||||||
|
hand_raised_at = (
|
||||||
|
timezone.now().isoformat() if serializer.validated_data["raised"] else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ParticipantsManagement().update(
|
||||||
|
room_name=str(room.pk),
|
||||||
|
identity=identity,
|
||||||
|
attributes={"handRaisedAt": hand_raised_at},
|
||||||
|
)
|
||||||
|
except ParticipantNotFoundException:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Participant not found"},
|
||||||
|
status=drf_status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
except ParticipantsManagementException:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Failed to update participant hand state"},
|
||||||
|
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{"status": "success"},
|
||||||
|
status=drf_status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=True,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="rename",
|
||||||
|
url_name="rename",
|
||||||
|
permission_classes=[permissions.HasLiveKitRoomAccess],
|
||||||
|
authentication_classes=[LiveKitTokenAuthentication],
|
||||||
|
)
|
||||||
|
def rename(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
|
"""Rename the current participant in the room."""
|
||||||
|
room = self.get_object()
|
||||||
|
|
||||||
|
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
identity = request.auth.identity
|
||||||
|
|
||||||
|
try:
|
||||||
|
ParticipantsManagement().update(
|
||||||
|
room_name=str(room.pk),
|
||||||
|
identity=identity,
|
||||||
|
name=serializer.validated_data["name"],
|
||||||
|
)
|
||||||
|
except ParticipantNotFoundException:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Participant not found"},
|
||||||
|
status=drf_status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
except ParticipantsManagementException:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Failed to rename participant"},
|
||||||
|
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(
|
||||||
|
{"status": "success"},
|
||||||
|
status=drf_status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessViewSet(
|
class ResourceAccessViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
@@ -757,10 +858,10 @@ class RecordingViewSet(
|
|||||||
recording_id = parser.get_recording_id(request.data)
|
recording_id = parser.get_recording_id(request.data)
|
||||||
|
|
||||||
except ParsingEventDataError as e:
|
except ParsingEventDataError as e:
|
||||||
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
|
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
|
||||||
|
|
||||||
except InvalidBucketError as e:
|
except InvalidBucketError as e:
|
||||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
|
||||||
|
|
||||||
except InvalidFilepathError:
|
except InvalidFilepathError:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
|
|||||||
@@ -14,10 +14,19 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
|||||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
token = request.data.get("token")
|
auth_header = request.headers.get("Authorization")
|
||||||
if not token:
|
|
||||||
|
if not auth_header:
|
||||||
return None # No authentication attempted
|
return None # No authentication attempted
|
||||||
|
|
||||||
|
parts = auth_header.split()
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
|
raise exceptions.AuthenticationFailed(
|
||||||
|
"Authorization header must be: Bearer <token>"
|
||||||
|
)
|
||||||
|
|
||||||
|
token = parts[1]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
verifier = TokenVerifier(
|
verifier = TokenVerifier(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
|
|||||||
|
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||||
f"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
|
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
|
||||||
)
|
)
|
||||||
|
|
||||||
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import smtplib
|
|||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import EmailMultiAlternatives
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.translation import get_language, override
|
from django.utils.translation import get_language, override
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -45,15 +45,18 @@ class InvitationService:
|
|||||||
)
|
)
|
||||||
) # Force translation
|
) # Force translation
|
||||||
|
|
||||||
|
email = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=msg_plain,
|
||||||
|
from_email=settings.EMAIL_FROM,
|
||||||
|
to=[],
|
||||||
|
bcc=emails,
|
||||||
|
)
|
||||||
|
|
||||||
|
email.attach_alternative(msg_html, "text/html")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_mail(
|
email.send()
|
||||||
subject,
|
|
||||||
msg_plain,
|
|
||||||
settings.EMAIL_FROM,
|
|
||||||
emails,
|
|
||||||
html_message=msg_html,
|
|
||||||
fail_silently=False,
|
|
||||||
)
|
|
||||||
except smtplib.SMTPException as e:
|
except smtplib.SMTPException as e:
|
||||||
logger.error("invitation to %s was not sent: %s", emails, e)
|
logger.error("invitations were not sent: %s", e)
|
||||||
raise InvitationError("Could not send invitation") from e
|
raise InvitationError("Could not send invitation") from e
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class ParticipantsManagementException(Exception):
|
|||||||
"""Exception raised when a participant management operations fail."""
|
"""Exception raised when a participant management operations fail."""
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantNotFoundException(ParticipantsManagementException):
|
||||||
|
"""Raised when the target participant does not exist in the room."""
|
||||||
|
|
||||||
|
|
||||||
class ParticipantsManagement:
|
class ParticipantsManagement:
|
||||||
"""Service for managing participants."""
|
"""Service for managing participants."""
|
||||||
|
|
||||||
@@ -47,6 +51,14 @@ class ParticipantsManagement:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
|
if e.code == "not_found":
|
||||||
|
logger.warning(
|
||||||
|
"Participant %s not found in room %s, skipping muting",
|
||||||
|
identity,
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
raise ParticipantNotFoundException("Participant does not exist") from e
|
||||||
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Unexpected error muting participant %s for room %s",
|
"Unexpected error muting participant %s for room %s",
|
||||||
identity,
|
identity,
|
||||||
@@ -80,6 +92,14 @@ class ParticipantsManagement:
|
|||||||
RoomParticipantIdentity(room=room_name, identity=identity)
|
RoomParticipantIdentity(room=room_name, identity=identity)
|
||||||
)
|
)
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
|
if e.code == "not_found":
|
||||||
|
logger.warning(
|
||||||
|
"Participant %s not found in room %s, skipping removing",
|
||||||
|
identity,
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
raise ParticipantNotFoundException("Participant does not exist") from e
|
||||||
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Unexpected error removing participant %s for room %s",
|
"Unexpected error removing participant %s for room %s",
|
||||||
identity,
|
identity,
|
||||||
@@ -117,6 +137,14 @@ class ParticipantsManagement:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
|
if e.code == "not_found":
|
||||||
|
logger.warning(
|
||||||
|
"Participant %s not found in room %s, skipping update",
|
||||||
|
identity,
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
raise ParticipantNotFoundException("Participant does not exist") from e
|
||||||
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Unexpected error updating participant %s for room %s",
|
"Unexpected error updating participant %s for room %s",
|
||||||
identity,
|
identity,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {"detail": "Invalid request data: Error message"}
|
assert response.json() == {"detail": "Invalid request data."}
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
||||||
@@ -112,7 +112,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {"detail": "Invalid bucket specified"}
|
assert response.json() == {"detail": "Invalid bucket specified."}
|
||||||
|
|
||||||
|
|
||||||
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
||||||
|
|||||||
@@ -240,10 +240,9 @@ def test_api_rooms_invite_error(mock_invite_to_room):
|
|||||||
mock_invite_to_room.assert_called_once()
|
mock_invite_to_room.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.invitation.send_mail")
|
@mock.patch("core.services.invitation.EmailMultiAlternatives")
|
||||||
def test_api_rooms_invite_success(mock_send_mail, settings):
|
def test_api_rooms_invite_success(mock_email_class, settings):
|
||||||
"""Test privileged users should successfully send invitation emails."""
|
"""Test privileged users should successfully send invitation emails."""
|
||||||
|
|
||||||
settings.EMAIL_BRAND_NAME = "ACME"
|
settings.EMAIL_BRAND_NAME = "ACME"
|
||||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||||
settings.EMAIL_APP_BASE_URL = "https://acme.com"
|
settings.EMAIL_APP_BASE_URL = "https://acme.com"
|
||||||
@@ -255,7 +254,6 @@ def test_api_rooms_invite_success(mock_send_mail, settings):
|
|||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
|
|
||||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||||
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
|
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
|
||||||
@@ -269,26 +267,38 @@ def test_api_rooms_invite_success(mock_send_mail, settings):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"status": "success", "message": "invitations sent"}
|
assert response.json() == {"status": "success", "message": "invitations sent"}
|
||||||
|
|
||||||
mock_send_mail.assert_called_once()
|
mock_email_class.assert_called_once()
|
||||||
|
|
||||||
subject, body, sender, recipients = mock_send_mail.call_args[0]
|
# Check constructor arguments
|
||||||
|
call_kwargs = mock_email_class.call_args[1] # EmailMultiAlternatives(**kwargs)
|
||||||
|
|
||||||
assert (
|
assert call_kwargs["subject"] == (
|
||||||
subject == f"Video call in progress: {user.email} is waiting for you to connect"
|
f"Video call in progress: {user.email} is waiting for you to connect"
|
||||||
|
)
|
||||||
|
assert call_kwargs["from_email"] == "notifications@acme.com"
|
||||||
|
assert call_kwargs["to"] == []
|
||||||
|
assert sorted(call_kwargs["bcc"]) == sorted(
|
||||||
|
["fabien@yopmail.com", "gerald@yopmail.com"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify email contains expected content
|
# Check plain text body
|
||||||
|
plain_body = call_kwargs["body"]
|
||||||
required_content = [
|
required_content = [
|
||||||
"ACME", # Brand name
|
"ACME",
|
||||||
"https://acme.com/logo", # Logo URL
|
"https://acme.com/logo",
|
||||||
f"https://acme.com/{room.slug}", # Room url
|
f"https://acme.com/{room.slug}",
|
||||||
f"acme.com/{room.slug}", # Room link
|
f"acme.com/{room.slug}",
|
||||||
]
|
]
|
||||||
|
|
||||||
for content in required_content:
|
for content in required_content:
|
||||||
assert content in body
|
assert content in plain_body
|
||||||
|
|
||||||
assert sender == "notifications@acme.com"
|
# Check HTML alternative was attached
|
||||||
|
mock_instance = mock_email_class.return_value
|
||||||
|
mock_instance.attach_alternative.assert_called_once()
|
||||||
|
html_body, mimetype = mock_instance.attach_alternative.call_args[0]
|
||||||
|
assert mimetype == "text/html"
|
||||||
|
for content in required_content:
|
||||||
|
assert content in html_body
|
||||||
|
|
||||||
# Verify all owners received the email (order-independent comparison)
|
# Check send was called
|
||||||
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
|
mock_instance.send.assert_called_once()
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
|
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
|
||||||
msg="Internal server error", code=500, status=500
|
msg="Internal server error", code="unknown", status=500
|
||||||
)
|
)
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
@@ -130,10 +130,11 @@ def test_update_participant_success(mock_livekit_client):
|
|||||||
"can_publish": True,
|
"can_publish": True,
|
||||||
"can_publish_data": True,
|
"can_publish_data": True,
|
||||||
"can_publish_sources": [
|
"can_publish_sources": [
|
||||||
1,
|
"CAMERA",
|
||||||
2,
|
"MICROPHONE",
|
||||||
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
|
],
|
||||||
"can_update_metadata": True,
|
"can_update_metadata": True,
|
||||||
|
"can_subscribe_metrics": True,
|
||||||
},
|
},
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
}
|
}
|
||||||
@@ -155,8 +156,14 @@ def test_update_participant_success(mock_livekit_client):
|
|||||||
{"can_subscribe": True},
|
{"can_subscribe": True},
|
||||||
{"can_publish": True},
|
{"can_publish": True},
|
||||||
{"can_publish_data": True},
|
{"can_publish_data": True},
|
||||||
{"can_publish_sources": [1, 2]},
|
{
|
||||||
|
"can_publish_sources": [
|
||||||
|
"CAMERA",
|
||||||
|
"MICROPHONE",
|
||||||
|
]
|
||||||
|
},
|
||||||
{"can_update_metadata": True},
|
{"can_update_metadata": True},
|
||||||
|
{"can_subscribe_metrics": False},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_update_participant_permission_fields_are_optional(
|
def test_update_participant_permission_fields_are_optional(
|
||||||
@@ -264,35 +271,6 @@ def test_update_participant_suspicious_permission_multiple(mock_suspicious):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", (False, True))
|
|
||||||
def test_update_participant_unimplemented_can_subscribe_metrics(value):
|
|
||||||
"""Test update participant raises 400 when can_subscribe_metrics is set."""
|
|
||||||
client = APIClient()
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
UserResourceAccessFactory(
|
|
||||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
|
||||||
)
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"participant_identity": str(uuid4()),
|
|
||||||
"permission": {
|
|
||||||
"can_subscribe": True,
|
|
||||||
"can_publish": True,
|
|
||||||
"can_publish_data": True,
|
|
||||||
"can_update_metadata": False,
|
|
||||||
"can_subscribe_metrics": value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
|
||||||
response = client.post(url, payload, format="json")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
|
||||||
assert "can_subscribe_metrics" in str(response.data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_participant_forbidden_without_access():
|
def test_update_participant_forbidden_without_access():
|
||||||
"""Test update participant returns 403 when user lacks room privileges."""
|
"""Test update participant returns 403 when user lacks room privileges."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
@@ -409,7 +387,7 @@ def test_update_participant_unexpected_twirp_error(mock_livekit_client):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||||
msg="Internal server error", code=500, status=500
|
msg="Internal server error", code="unknown", status=500
|
||||||
)
|
)
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
@@ -548,7 +526,7 @@ def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
|
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
|
||||||
msg="Internal server error", code=500, status=500
|
msg="Internal server error", code="unknown", status=500
|
||||||
)
|
)
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
@@ -567,3 +545,55 @@ def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
|
|||||||
assert response.data == {"error": "Failed to remove participant"}
|
assert response.data == {"error": "Failed to remove participant"}
|
||||||
|
|
||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_participant_not_found(mock_livekit_client):
|
||||||
|
"""Test update participant returns 404 when the participant no longer exists in the room."""
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||||
|
msg="participant does not exist", code="not_found", status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
UserResourceAccessFactory(
|
||||||
|
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||||
|
)
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
|
||||||
|
|
||||||
|
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
assert response.data == {"error": "Participant not found"}
|
||||||
|
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_participant_not_found(mock_livekit_client):
|
||||||
|
"""Test remove participant returns 404 when the participant no longer exists in the room."""
|
||||||
|
client = APIClient()
|
||||||
|
|
||||||
|
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
|
||||||
|
msg="participant does not exist", code="not_found", status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
UserResourceAccessFactory(
|
||||||
|
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||||
|
)
|
||||||
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
payload = {"participant_identity": str(uuid4())}
|
||||||
|
|
||||||
|
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
assert response.data == {"error": "Participant not found"}
|
||||||
|
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|||||||
@@ -0,0 +1,574 @@
|
|||||||
|
"""
|
||||||
|
Test rooms API endpoints: toggle hand and rename participant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from freezegun import freeze_time
|
||||||
|
from livekit.api import TwirpError
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import utils
|
||||||
|
from core.factories import RoomFactory, UserFactory
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_livekit_client():
|
||||||
|
"""Mock LiveKit API client."""
|
||||||
|
with mock.patch("core.utils.create_livekit_client") as mock_create:
|
||||||
|
mock_client = mock.AsyncMock()
|
||||||
|
mock_create.return_value = mock_client
|
||||||
|
yield mock_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def room():
|
||||||
|
"""Create a room."""
|
||||||
|
return RoomFactory()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user():
|
||||||
|
"""Create a user."""
|
||||||
|
return UserFactory()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def token(room, user):
|
||||||
|
"""Generate a real LiveKit JWT for the user in the room."""
|
||||||
|
return utils.generate_token(room=str(room.id), user=user)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anonymous_token(room):
|
||||||
|
"""Generate a real LiveKit JWT for an anonymous user in the room."""
|
||||||
|
return utils.generate_token(
|
||||||
|
room=str(room.id),
|
||||||
|
user=AnonymousUser(),
|
||||||
|
participant_id="anon-participant-id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---
|
||||||
|
# toggle-hand
|
||||||
|
# ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_raise_success(mock_livekit_client, room, token):
|
||||||
|
"""Test successfully raising a participant's hand."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_lower_success(mock_livekit_client, room, token):
|
||||||
|
"""Test successfully lowering a participant's hand."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
|
assert call_kwargs[0][0].attributes["handRaisedAt"] == ""
|
||||||
|
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
|
||||||
|
"""Test that raising a hand sets a non-empty ISO timestamp as the attribute."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
|
||||||
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
|
assert call_kwargs[0][0].attributes["handRaisedAt"] != ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_identity_derived_from_token(
|
||||||
|
mock_livekit_client, room, token, user
|
||||||
|
):
|
||||||
|
"""Test that the participant identity is derived from the token, not supplied by the client."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
client.post(
|
||||||
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
|
assert call_kwargs[0][0].identity == str(user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_missing_raised_field(room, token):
|
||||||
|
"""Test toggle hand with missing raised field returns 400."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "raised" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_invalid_raised_field(room, token):
|
||||||
|
"""Test toggle hand with non-boolean raised field returns 400."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": "not-a-boolean"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_forbidden_without_token(room):
|
||||||
|
"""Test toggle hand returns 403 when no LiveKit token is provided."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, {"raised": True}, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_forbidden_token_for_wrong_room(user):
|
||||||
|
"""Test toggle hand returns 403 when the token is scoped to a different room."""
|
||||||
|
wrong_room = RoomFactory()
|
||||||
|
target_room = RoomFactory()
|
||||||
|
wrong_token = utils.generate_token(room=str(wrong_room.id), user=user)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
|
||||||
|
"""Test toggle hand when LiveKit API raises TwirpError."""
|
||||||
|
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||||
|
msg="Internal server error", code="unknown", status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
assert response.data == {"error": "Failed to update participant hand state"}
|
||||||
|
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_raise_success_anonymous(
|
||||||
|
mock_livekit_client, room, anonymous_token
|
||||||
|
):
|
||||||
|
"""Test successfully raising hand as an anonymous participant."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_lower_success_anonymous(
|
||||||
|
mock_livekit_client, room, anonymous_token
|
||||||
|
):
|
||||||
|
"""Test successfully lowering hand as an anonymous participant."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": False},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
|
assert call_kwargs[0][0].attributes["handRaisedAt"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_identity_derived_from_token_anonymous(
|
||||||
|
mock_livekit_client, room, anonymous_token
|
||||||
|
):
|
||||||
|
"""Test that identity is derived from participant_id for anonymous users."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
client.post(
|
||||||
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
|
assert call_kwargs[0][0].identity == "anon-participant-id"
|
||||||
|
|
||||||
|
|
||||||
|
# ---
|
||||||
|
# rename
|
||||||
|
# ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_success(mock_livekit_client, room, token):
|
||||||
|
"""Test successfully renaming a participant."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
|
||||||
|
"""Test that rename passes the correct name to LiveKit."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
client.post(
|
||||||
|
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
|
assert call_kwargs[0][0].name == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_uses_identity_from_token(
|
||||||
|
mock_livekit_client, room, token, user
|
||||||
|
):
|
||||||
|
"""Test that rename derives participant identity from the LiveKit token, not the request."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
client.post(
|
||||||
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
|
assert call_kwargs[0][0].identity == str(user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_empty_name(room, token):
|
||||||
|
"""Test rename with an empty name returns 400."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "name" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_missing_name(room, token):
|
||||||
|
"""Test rename with missing name field returns 400."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "name" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_name_too_long(room, token):
|
||||||
|
"""Test rename with a name exceeding 255 characters returns 400."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
assert "name" in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_forbidden_without_token(room):
|
||||||
|
"""Test rename returns 403 when no LiveKit token is provided."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(url, {"name": "John Doe"}, format="json")
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_forbidden_token_for_wrong_room(user):
|
||||||
|
"""Test rename returns 403 when the token is scoped to a different room."""
|
||||||
|
wrong_room = RoomFactory()
|
||||||
|
target_room = RoomFactory()
|
||||||
|
wrong_token = utils.generate_token(room=str(wrong_room.id), user=user)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": target_room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, token):
|
||||||
|
"""Test rename when LiveKit API raises TwirpError."""
|
||||||
|
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||||
|
msg="Internal server error", code="unknown", status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
assert response.data == {"error": "Failed to rename participant"}
|
||||||
|
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_success_anonymous(
|
||||||
|
mock_livekit_client, room, anonymous_token
|
||||||
|
):
|
||||||
|
"""Test successfully renaming an anonymous participant."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "Guest User"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
assert response.data == {"status": "success"}
|
||||||
|
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_uses_identity_from_token_anonymous(
|
||||||
|
mock_livekit_client, room, anonymous_token
|
||||||
|
):
|
||||||
|
"""Test that rename derives identity from participant_id for anonymous users."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
client.post(
|
||||||
|
url,
|
||||||
|
{"name": "Guest User"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
|
assert call_kwargs[0][0].identity == "anon-participant-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_sets_correct_name_anonymous(
|
||||||
|
mock_livekit_client, room, anonymous_token
|
||||||
|
):
|
||||||
|
"""Test that rename passes the correct name to LiveKit for anonymous users."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
client.post(
|
||||||
|
url,
|
||||||
|
{"name": "Guest User"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_kwargs = mock_livekit_client.room.update_participant.call_args
|
||||||
|
assert call_kwargs[0][0].name == "Guest User"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_token):
|
||||||
|
"""Test rename returns 403 when anonymous token is scoped to a different room."""
|
||||||
|
target_room = RoomFactory()
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": target_room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "Guest User"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
# ---
|
||||||
|
# expired / malformed / missing room — shared cases
|
||||||
|
# ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def expired_token(room, user):
|
||||||
|
"""Generate a LiveKit JWT frozen in the past, guaranteed to be expired."""
|
||||||
|
return utils.generate_token(room=str(room.id), user=user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_expired_token(room, expired_token):
|
||||||
|
"""Test toggle hand returns 403 when the LiveKit token is expired."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_expired_token(room, expired_token):
|
||||||
|
"""Test rename returns 403 when the LiveKit token is expired."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_malformed_token(room):
|
||||||
|
"""Test toggle hand returns 403 when the LiveKit token is malformed."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"raised": True},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_room_not_found(user):
|
||||||
|
"""Test toggle hand returns 404 when the room does not exist."""
|
||||||
|
non_existent_room_id = uuid4()
|
||||||
|
token = utils.generate_token(room=str(non_existent_room_id), user=user)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
|
||||||
|
"""Test toggle hand returns 404 when the participant no longer exists in the room."""
|
||||||
|
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||||
|
msg="participant does not exist", code="not_found", status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
assert response.data == {"error": "Participant not found"}
|
||||||
|
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_malformed_token(room):
|
||||||
|
"""Test rename returns 403 when the LiveKit token is malformed."""
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "John Doe"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_room_not_found(user):
|
||||||
|
"""Test rename returns 404 when the room does not exist."""
|
||||||
|
non_existent_room_id = uuid4()
|
||||||
|
token = utils.generate_token(room=str(non_existent_room_id), user=user)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_not_found(mock_livekit_client, room, token):
|
||||||
|
"""Test rename returns 404 when the participant no longer exists in the room."""
|
||||||
|
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||||
|
msg="participant does not exist", code="not_found", status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||||
|
assert response.data == {"error": "Participant not found"}
|
||||||
|
|
||||||
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
@@ -108,7 +108,9 @@ def test_start_subtitle_invalid_token():
|
|||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/", {"token": "invalid-token"}
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
|
{},
|
||||||
|
HTTP_AUTHORIZATION="Bearer invalid-token",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -125,7 +127,8 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
|||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{"token": mock_livekit_token},
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
@@ -144,7 +147,8 @@ def test_start_subtitle_valid_token(
|
|||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{"token": mock_livekit_token},
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -168,12 +172,13 @@ def test_start_subtitle_twirp_error(
|
|||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError(
|
mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError(
|
||||||
msg="Internal server error", code=500, status=500
|
msg="Internal server error", code="unknown", status=500
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{"token": mock_livekit_token},
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
@@ -192,7 +197,8 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
|
|||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{"token": mock_livekit_token},
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -205,14 +211,15 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
|||||||
"""Test that tokens signed with incorrect signature are rejected."""
|
"""Test that tokens signed with incorrect signature are rejected."""
|
||||||
|
|
||||||
settings.ROOM_SUBTITLE_ENABLED = True
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
|
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret-padded-to-32-bytes!!"
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||||
{"token": mock_livekit_token},
|
{},
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def mock_livekit_config(settings):
|
|||||||
"""Mock LiveKit configuration."""
|
"""Mock LiveKit configuration."""
|
||||||
settings.LIVEKIT_CONFIGURATION = {
|
settings.LIVEKIT_CONFIGURATION = {
|
||||||
"api_key": "test_api_key",
|
"api_key": "test_api_key",
|
||||||
"api_secret": "test_api_secret",
|
"api_secret": "test_api_secret_padded_to_32bytes!",
|
||||||
"url": "https://test-livekit.example.com/",
|
"url": "https://test-livekit.example.com/",
|
||||||
}
|
}
|
||||||
return settings.LIVEKIT_CONFIGURATION
|
return settings.LIVEKIT_CONFIGURATION
|
||||||
@@ -77,7 +77,6 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "Authorization header missing",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +90,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
|
assert response.json() == {"status": "error"}
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_event_type(client, mock_livekit_config):
|
def test_unknown_event_type(client, mock_livekit_config):
|
||||||
@@ -116,7 +115,6 @@ def test_unknown_event_type(client, mock_livekit_config):
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": "Unknown webhook type: unknown_event_type",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
"""
|
||||||
|
Tests for JWT token service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0212,W0621
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
import jwt as pyjwt
|
||||||
|
import pytest
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from core.services.jwt_token import (
|
||||||
|
JwtTokenService,
|
||||||
|
TokenDecodeError,
|
||||||
|
TokenExpiredError,
|
||||||
|
TokenInvalidError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Fixtures --
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def jwt_service():
|
||||||
|
"""Create a JWT token service for testing."""
|
||||||
|
return JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="test-issuer",
|
||||||
|
audience="test-audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_user():
|
||||||
|
"""Create a mock user with a string ID."""
|
||||||
|
user = mock.Mock()
|
||||||
|
user.id = "test-user-id"
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# -- __init__ / Configuration --
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_missing_secret_key():
|
||||||
|
"""Missing secret key should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_none_secret_key():
|
||||||
|
"""None secret key should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key=None,
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_missing_algorithm():
|
||||||
|
"""Missing algorithm should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_none_algorithm():
|
||||||
|
"""None algorithm should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm=None,
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_missing_token_type():
|
||||||
|
"""Missing token type should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_none_token_type():
|
||||||
|
"""None token type should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_none_expiration_seconds():
|
||||||
|
"""None expiration seconds should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Expiration's seconds is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=None,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_zero_expiration_seconds_is_accepted():
|
||||||
|
"""expiration_seconds=0 is falsy but should be accepted — token expires immediately."""
|
||||||
|
service = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=0,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
assert service._expiration_seconds == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_stores_config_correctly():
|
||||||
|
"""All config values should be stored correctly on the instance."""
|
||||||
|
service = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="my-issuer",
|
||||||
|
audience="my-audience",
|
||||||
|
expiration_seconds=1800,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
assert service._key == "test-secret-padded-to-32-bytes!!"
|
||||||
|
assert service._algorithm == "HS256"
|
||||||
|
assert service._issuer == "my-issuer"
|
||||||
|
assert service._audience == "my-audience"
|
||||||
|
assert service._expiration_seconds == 1800
|
||||||
|
assert service._token_type == "Bearer"
|
||||||
|
|
||||||
|
|
||||||
|
# -- generate_jwt / Return shape --
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_always_returns_required_keys(jwt_service, mock_user):
|
||||||
|
"""Response always contains access_token, token_type, and expires_in."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
|
||||||
|
assert "access_token" in result
|
||||||
|
assert "token_type" in result
|
||||||
|
assert "expires_in" in result
|
||||||
|
assert result["token_type"] == "Bearer"
|
||||||
|
assert result["expires_in"] == 3600
|
||||||
|
assert isinstance(result["access_token"], str)
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_scope_present_when_provided(jwt_service, mock_user):
|
||||||
|
"""scope key should be present in response when scope is provided."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read write")
|
||||||
|
|
||||||
|
assert result["scope"] == "read write"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_scope_absent_when_empty(jwt_service, mock_user):
|
||||||
|
"""scope key should be absent from response when scope is empty."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="")
|
||||||
|
|
||||||
|
assert "scope" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_scope_absent_when_none(jwt_service, mock_user):
|
||||||
|
"""scope key should be absent from response when scope is None."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope=None)
|
||||||
|
|
||||||
|
assert "scope" not in result
|
||||||
|
|
||||||
|
|
||||||
|
# -- generate_jwt / Payload correctness --
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_payload_contains_required_claims(jwt_service, mock_user):
|
||||||
|
"""Payload should always contain iat, exp, and user_id."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["iat"] == 1673784000
|
||||||
|
assert payload["exp"] == 1673787600
|
||||||
|
assert payload["user_id"] == "test-user-id"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_exp_is_now_plus_expiration_seconds(mock_user):
|
||||||
|
"""exp should equal iat + expiration_seconds exactly."""
|
||||||
|
service = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=900,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["exp"] - payload["iat"] == 900
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_iss_included_when_set(jwt_service, mock_user):
|
||||||
|
"""iss should be present in payload when issuer is non-empty."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["iss"] == "test-issuer"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_aud_included_when_set(jwt_service, mock_user):
|
||||||
|
"""aud should be present in payload when audience is non-empty."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["aud"] == "test-audience"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_iss_absent_when_empty(mock_user):
|
||||||
|
"""iss should be absent from payload when issuer is empty string."""
|
||||||
|
|
||||||
|
service = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="",
|
||||||
|
audience="",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = pyjwt.decode(
|
||||||
|
result["access_token"],
|
||||||
|
"test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "iss" not in payload
|
||||||
|
assert "aud" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_iss_absent_when_none(mock_user):
|
||||||
|
"""iss should be absent from payload when issuer is None."""
|
||||||
|
|
||||||
|
service = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer=None,
|
||||||
|
audience=None,
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = pyjwt.decode(
|
||||||
|
result["access_token"],
|
||||||
|
"test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "iss" not in payload
|
||||||
|
assert "aud" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_scope_absent_from_payload_when_empty(jwt_service, mock_user):
|
||||||
|
"""scope should be absent from payload when not provided."""
|
||||||
|
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="")
|
||||||
|
payload = pyjwt.decode(
|
||||||
|
result["access_token"],
|
||||||
|
"test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithms=["HS256"],
|
||||||
|
issuer="test-issuer",
|
||||||
|
audience="test-audience",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "scope" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
# -- generate_jwt / extra_payload handling --
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_none_does_not_crash(jwt_service, mock_user):
|
||||||
|
"""extra_payload=None should not crash and produce a valid token."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload=None)
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["user_id"] == "test-user-id"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_non_colliding_keys_preserved(
|
||||||
|
jwt_service, mock_user
|
||||||
|
):
|
||||||
|
"""Non-colliding extra_payload keys should appear in decoded token."""
|
||||||
|
result = jwt_service.generate_jwt(
|
||||||
|
mock_user,
|
||||||
|
scope="read",
|
||||||
|
extra_payload={"client_id": "my-app", "delegated": True},
|
||||||
|
)
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["client_id"] == "my-app"
|
||||||
|
assert payload["delegated"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_colliding_iat_overwritten(jwt_service, mock_user):
|
||||||
|
"""iat in extra_payload should be overwritten by the service."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload={"iat": 0})
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["iat"] == 1673784000
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_colliding_exp_overwritten(jwt_service, mock_user):
|
||||||
|
"""exp in extra_payload should be overwritten by the service."""
|
||||||
|
result = jwt_service.generate_jwt(
|
||||||
|
mock_user, scope="read", extra_payload={"exp": 9999999999}
|
||||||
|
)
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["exp"] == 1673787600
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_colliding_user_id_overwritten(
|
||||||
|
jwt_service, mock_user
|
||||||
|
):
|
||||||
|
"""user_id in extra_payload should be overwritten by the service."""
|
||||||
|
result = jwt_service.generate_jwt(
|
||||||
|
mock_user, scope="read", extra_payload={"user_id": "hacked"}
|
||||||
|
)
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["user_id"] == "test-user-id"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_not_mutated(jwt_service, mock_user):
|
||||||
|
"""generate_jwt should not mutate the original extra_payload dict."""
|
||||||
|
extra = {"client_id": "my-app"}
|
||||||
|
jwt_service.generate_jwt(mock_user, scope="read", extra_payload=extra)
|
||||||
|
|
||||||
|
assert extra == {"client_id": "my-app"}
|
||||||
|
|
||||||
|
|
||||||
|
# -- generate_jwt / user.id casting --
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_user_id_cast_from_uuid(jwt_service):
|
||||||
|
"""user.id as UUID should be cast to str in payload."""
|
||||||
|
user = mock.Mock()
|
||||||
|
user.id = uuid.UUID("12345678-1234-5678-1234-567812345678")
|
||||||
|
result = jwt_service.generate_jwt(user, scope="read")
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["user_id"] == "12345678-1234-5678-1234-567812345678"
|
||||||
|
|
||||||
|
|
||||||
|
# -- decode_jwt / Happy path --
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_roundtrip(jwt_service, mock_user):
|
||||||
|
"""Valid token should decode to correct payload."""
|
||||||
|
with freeze_time("2023-01-15 12:00:00"):
|
||||||
|
result = jwt_service.generate_jwt(
|
||||||
|
mock_user, scope="read", extra_payload={"client_id": "my-app"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with freeze_time("2023-01-15 12:30:00"):
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["user_id"] == "test-user-id"
|
||||||
|
assert payload["scope"] == "read"
|
||||||
|
assert payload["client_id"] == "my-app"
|
||||||
|
assert payload["iss"] == "test-issuer"
|
||||||
|
assert payload["aud"] == "test-audience"
|
||||||
|
|
||||||
|
|
||||||
|
# -- decode_jwt / Error mapping --
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_expired_raises_token_expired_error(jwt_service, mock_user):
|
||||||
|
"""Expired token should raise TokenExpiredError."""
|
||||||
|
with freeze_time("2023-01-15 12:00:00"):
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
|
||||||
|
with freeze_time("2099-01-01 00:00:00"):
|
||||||
|
with pytest.raises(TokenExpiredError):
|
||||||
|
jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_wrong_issuer_raises_token_invalid_error(mock_user):
|
||||||
|
"""Token with wrong issuer should raise TokenInvalidError."""
|
||||||
|
service_a = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer-a",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
service_b = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer-b",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service_a.generate_jwt(mock_user, scope="read")
|
||||||
|
|
||||||
|
with pytest.raises(TokenInvalidError):
|
||||||
|
service_b.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_wrong_audience_raises_token_invalid_error(mock_user):
|
||||||
|
"""Token with wrong audience should raise TokenInvalidError."""
|
||||||
|
service_a = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience-a",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
service_b = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience-b",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service_a.generate_jwt(mock_user, scope="read")
|
||||||
|
|
||||||
|
with pytest.raises(TokenInvalidError):
|
||||||
|
service_b.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_tampered_signature_raises_token_decode_error(
|
||||||
|
jwt_service, mock_user
|
||||||
|
):
|
||||||
|
"""Token with tampered signature should raise TokenDecodeError."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
header, payload, _ = result["access_token"].split(".")
|
||||||
|
tampered_token = f"{header}.{payload}.invalidsignature"
|
||||||
|
|
||||||
|
with pytest.raises(TokenDecodeError):
|
||||||
|
jwt_service.decode_jwt(tampered_token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_garbage_string_raises_token_decode_error(jwt_service):
|
||||||
|
"""Garbage string should raise TokenDecodeError."""
|
||||||
|
with pytest.raises(TokenDecodeError):
|
||||||
|
jwt_service.decode_jwt("this.is.not.a.valid.token")
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_empty_string_raises_token_decode_error(jwt_service):
|
||||||
|
"""Empty string should raise TokenDecodeError."""
|
||||||
|
with pytest.raises(TokenDecodeError):
|
||||||
|
jwt_service.decode_jwt("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_none_raises_token_decode_error(jwt_service):
|
||||||
|
"""None should raise TokenDecodeError."""
|
||||||
|
with pytest.raises(TokenDecodeError):
|
||||||
|
jwt_service.decode_jwt(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_algorithm_mismatch_raises_token_decode_error(mock_user):
|
||||||
|
"""Token encoded with HS256 decoded expecting RS256 should raise TokenDecodeError."""
|
||||||
|
service_hs256 = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
service_rs256 = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="RS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service_hs256.generate_jwt(mock_user, scope="read")
|
||||||
|
|
||||||
|
with pytest.raises(TokenDecodeError):
|
||||||
|
service_rs256.decode_jwt(result["access_token"])
|
||||||
@@ -72,7 +72,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
|||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
|||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
|
|||||||
if call_count == 0:
|
if call_count == 0:
|
||||||
call_count += 1
|
call_count += 1
|
||||||
return None
|
return None
|
||||||
raise TwirpError(msg="Deletion failed", code=500, status=500)
|
raise TwirpError(msg="Deletion failed", code="unknown", status=500)
|
||||||
|
|
||||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
||||||
side_effect=delete_side_effect
|
side_effect=delete_side_effect
|
||||||
@@ -294,7 +294,7 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
|||||||
mock_list_rules.return_value = ["rule-1"]
|
mock_list_rules.return_value = ["rule-1"]
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
||||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
|
|||||||
@@ -713,7 +713,7 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
|||||||
}
|
}
|
||||||
token = jwt.encode(
|
token = jwt.encode(
|
||||||
payload,
|
payload,
|
||||||
"invalid-private-key",
|
"invalid-private-key-padded-to-32b!",
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
algorithm=settings.APPLICATION_JWT_ALG,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ def generate_token(
|
|||||||
room=room,
|
room=room,
|
||||||
room_join=True,
|
room_join=True,
|
||||||
room_admin=is_admin_or_owner,
|
room_admin=is_admin_or_owner,
|
||||||
can_update_own_metadata=True,
|
can_update_own_metadata=False,
|
||||||
can_publish=bool(sources),
|
can_publish=bool(sources),
|
||||||
can_publish_sources=sources,
|
can_publish_sources=sources,
|
||||||
can_subscribe=True,
|
can_subscribe=True,
|
||||||
|
|||||||
@@ -452,7 +452,11 @@ class Base(Configuration):
|
|||||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
|
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
|
||||||
|
|
||||||
# Session
|
# Session
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
SESSION_ENGINE = values.Value(
|
||||||
|
default="django.contrib.sessions.backends.cache",
|
||||||
|
environ_name="SESSION_ENGINE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
SESSION_CACHE_ALIAS = "default"
|
SESSION_CACHE_ALIAS = "default"
|
||||||
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
|
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
|
||||||
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
|
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
|
||||||
@@ -977,7 +981,13 @@ class Test(Base):
|
|||||||
USE_SWAGGER = True
|
USE_SWAGGER = True
|
||||||
EXTERNAL_API_ENABLED = True
|
EXTERNAL_API_ENABLED = True
|
||||||
|
|
||||||
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
|
LIVEKIT_CONFIGURATION = {
|
||||||
|
"api_key": "devkey-padded-for-minimum-len!-livekit",
|
||||||
|
"api_secret": "secret-key-padded-for-minimum-len!-livekit",
|
||||||
|
"url": "http://127.0.0.1.nip.io:7880",
|
||||||
|
}
|
||||||
|
|
||||||
|
APPLICATION_JWT_SECRET_KEY = "secret-key-padded-for-minimum-len!-application" # noqa:S105
|
||||||
APPLICATION_JWT_AUDIENCE = "Test inc."
|
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||||
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = True
|
CELERY_TASK_ALWAYS_EAGER = True
|
||||||
|
|||||||
+24
-19
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.10.0"
|
version = "1.14.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",
|
||||||
@@ -24,43 +24,43 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.42.49",
|
"boto3==1.42.68",
|
||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
"brevo-python==1.2.0",
|
"brevo-python==1.2.0",
|
||||||
"celery[redis]==5.6.2",
|
"celery[redis]==5.6.2",
|
||||||
"dj-database-url==3.1.0",
|
"dj-database-url==3.1.2",
|
||||||
"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.2.0",
|
"django-countries==8.2.0",
|
||||||
"django-filter==25.2",
|
"django-filter==25.2",
|
||||||
"django-lasuite[all]==0.0.24",
|
"django-lasuite[all]==0.0.25",
|
||||||
"django-parler==2.3",
|
"django-parler==2.3",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"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-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
"django==5.2.12",
|
"django==5.2.13",
|
||||||
"djangorestframework==3.16.1",
|
"djangorestframework==3.16.1",
|
||||||
"drf_spectacular==0.29.0",
|
"drf_spectacular==0.29.0",
|
||||||
"dockerflow==2026.1.26",
|
"dockerflow==2026.3.4",
|
||||||
"easy_thumbnails==2.10.1",
|
"easy_thumbnails==2.10.1",
|
||||||
"factory_boy==3.3.3",
|
"factory_boy==3.3.3",
|
||||||
"gunicorn==25.1.0",
|
"gunicorn==25.1.0",
|
||||||
"jsonschema==4.26.0",
|
"jsonschema==4.26.0",
|
||||||
"markdown==3.10.2",
|
"markdown==3.10.2",
|
||||||
"nested-multipart-parser==1.6.0",
|
"nested-multipart-parser==1.6.0",
|
||||||
"psycopg[binary]==3.3.2",
|
"psycopg[binary]==3.3.3",
|
||||||
"pydantic==2.12.4",
|
"pydantic==2.12.5",
|
||||||
"PyJWT==2.11.0",
|
"PyJWT==2.12.1",
|
||||||
"python-frontmatter==1.1.0",
|
"python-frontmatter==1.1.0",
|
||||||
"python-magic==0.4.27",
|
"python-magic==0.4.27",
|
||||||
"requests==2.32.5",
|
"requests==2.33.0",
|
||||||
"sentry-sdk==2.53.0",
|
"sentry-sdk==2.54.0",
|
||||||
"whitenoise==6.11.0",
|
"whitenoise==6.12.0",
|
||||||
"mozilla-django-oidc==5.0.2",
|
"mozilla-django-oidc==5.0.2",
|
||||||
"livekit-api==1.1.0",
|
"livekit-api==1.1.0",
|
||||||
"aiohttp==3.13.3",
|
"aiohttp==3.13.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -72,24 +72,29 @@ dependencies = [
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
"drf-spectacular-sidecar==2026.1.1",
|
"drf-spectacular-sidecar==2026.3.1",
|
||||||
"freezegun==1.5.5",
|
"freezegun==1.5.5",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==9.10.0",
|
"ipython==9.11.0",
|
||||||
"pyfakefs==6.1.1",
|
"pyfakefs==6.1.5",
|
||||||
"pylint-django==2.7.0",
|
"pylint-django==2.7.0",
|
||||||
"pylint<4.0.0",
|
"pylint<4.0.0",
|
||||||
"pytest-cov==7.0.0",
|
"pytest-cov==7.0.0",
|
||||||
"pytest-django==4.12.0",
|
"pytest-django==4.12.0",
|
||||||
"pytest==9.0.2",
|
"pytest==9.0.3",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.8.0",
|
"pytest-xdist==3.8.0",
|
||||||
"responses==0.25.8",
|
"responses==0.26.0",
|
||||||
"ruff==0.15.1",
|
"ruff==0.15.6",
|
||||||
"types-requests==2.32.4.20260107",
|
"types-requests==2.32.4.20260107",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.build-backend]
|
[tool.uv.build-backend]
|
||||||
|
module-name = [
|
||||||
|
"core",
|
||||||
|
"demo",
|
||||||
|
"meet"
|
||||||
|
]
|
||||||
module-root = ""
|
module-root = ""
|
||||||
source-exclude = [
|
source-exclude = [
|
||||||
"**/tests/**",
|
"**/tests/**",
|
||||||
|
|||||||
Generated
+414
-398
File diff suppressed because it is too large
Load Diff
+12
-8
@@ -1,5 +1,7 @@
|
|||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:20-alpine AS frontend-deps
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
COPY ./src/frontend/package.json ./package.json
|
COPY ./src/frontend/package.json ./package.json
|
||||||
@@ -8,7 +10,7 @@ COPY ./src/frontend/package-lock.json ./package-lock.json
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY .dockerignore ./.dockerignore
|
COPY .dockerignore ./.dockerignore
|
||||||
COPY ./src/frontend/ .
|
COPY --chown=node:node ./src/frontend/ .
|
||||||
|
|
||||||
### ---- Front-end builder image ----
|
### ---- Front-end builder image ----
|
||||||
FROM frontend-deps AS meet
|
FROM frontend-deps AS meet
|
||||||
@@ -17,6 +19,8 @@ WORKDIR /home/frontend
|
|||||||
|
|
||||||
FROM frontend-deps AS meet-dev
|
FROM frontend-deps AS meet-dev
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
@@ -35,15 +39,15 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
RUN apk update && apk upgrade libssl3 \
|
|
||||||
libcrypto3 \
|
# Security patches for known CVEs
|
||||||
libxml2>=2.12.7-r2 \
|
RUN apk update && apk upgrade \
|
||||||
libxslt>=1.1.39-r2 \
|
musl \
|
||||||
libexpat>=2.7.2-r0 \
|
musl-utils \
|
||||||
libpng>=1.6.53-r0 \
|
zlib>=1.3.2-r0 \
|
||||||
&& apk del curl
|
&& apk del curl
|
||||||
|
|
||||||
USER nginx
|
USER nginx
|
||||||
|
|||||||
Generated
+133
-129
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.10.0",
|
"version": "1.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.10.0",
|
"version": "1.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||||
"@fontsource/material-icons-outlined": "5.2.6",
|
"@fontsource/material-icons-outlined": "5.2.6",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.2",
|
||||||
"vite-tsconfig-paths": "6.1.1"
|
"vite-tsconfig-paths": "6.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -822,9 +822,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -6269,10 +6269,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
@@ -7158,10 +7161,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.1",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.3",
|
"version": "0.3.3",
|
||||||
@@ -7628,9 +7632,9 @@
|
|||||||
"integrity": "sha512-Rf4YVNYpKjZ6ASAmibcwTNciQ5Co5Ztq6iZPEykHpkoflnD/K5ryE/rHehFsTm4NJj8nKDhbi3eKBWGogmNnkg=="
|
"integrity": "sha512-Rf4YVNYpKjZ6ASAmibcwTNciQ5Co5Ztq6iZPEykHpkoflnD/K5ryE/rHehFsTm4NJj8nKDhbi3eKBWGogmNnkg=="
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.2",
|
"version": "4.12.12",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||||
"integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
|
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -11107,9 +11111,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.23.0",
|
"version": "6.24.1",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||||
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
|
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
@@ -11330,9 +11334,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -11420,9 +11424,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -11437,9 +11441,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/android-arm": {
|
"node_modules/vite/node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -11454,9 +11458,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/android-arm64": {
|
"node_modules/vite/node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -11471,9 +11475,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/android-x64": {
|
"node_modules/vite/node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -11488,9 +11492,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
|
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -11505,9 +11509,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
|
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -11522,9 +11526,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -11539,9 +11543,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
|
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -11556,9 +11560,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-arm": {
|
"node_modules/vite/node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -11573,9 +11577,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
|
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -11590,9 +11594,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
|
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -11607,9 +11611,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
|
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -11624,9 +11628,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
|
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -11641,9 +11645,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
|
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -11658,9 +11662,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
|
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -11675,9 +11679,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
|
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -11692,9 +11696,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/linux-x64": {
|
"node_modules/vite/node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -11709,9 +11713,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -11726,9 +11730,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
|
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -11743,9 +11747,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -11760,9 +11764,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
|
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -11777,9 +11781,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
|
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -11794,9 +11798,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
|
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -11811,9 +11815,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
|
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -11828,9 +11832,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/@esbuild/win32-x64": {
|
"node_modules/vite/node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -11845,9 +11849,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/esbuild": {
|
"node_modules/vite/node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -11858,32 +11862,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.27.3",
|
"@esbuild/aix-ppc64": "0.27.7",
|
||||||
"@esbuild/android-arm": "0.27.3",
|
"@esbuild/android-arm": "0.27.7",
|
||||||
"@esbuild/android-arm64": "0.27.3",
|
"@esbuild/android-arm64": "0.27.7",
|
||||||
"@esbuild/android-x64": "0.27.3",
|
"@esbuild/android-x64": "0.27.7",
|
||||||
"@esbuild/darwin-arm64": "0.27.3",
|
"@esbuild/darwin-arm64": "0.27.7",
|
||||||
"@esbuild/darwin-x64": "0.27.3",
|
"@esbuild/darwin-x64": "0.27.7",
|
||||||
"@esbuild/freebsd-arm64": "0.27.3",
|
"@esbuild/freebsd-arm64": "0.27.7",
|
||||||
"@esbuild/freebsd-x64": "0.27.3",
|
"@esbuild/freebsd-x64": "0.27.7",
|
||||||
"@esbuild/linux-arm": "0.27.3",
|
"@esbuild/linux-arm": "0.27.7",
|
||||||
"@esbuild/linux-arm64": "0.27.3",
|
"@esbuild/linux-arm64": "0.27.7",
|
||||||
"@esbuild/linux-ia32": "0.27.3",
|
"@esbuild/linux-ia32": "0.27.7",
|
||||||
"@esbuild/linux-loong64": "0.27.3",
|
"@esbuild/linux-loong64": "0.27.7",
|
||||||
"@esbuild/linux-mips64el": "0.27.3",
|
"@esbuild/linux-mips64el": "0.27.7",
|
||||||
"@esbuild/linux-ppc64": "0.27.3",
|
"@esbuild/linux-ppc64": "0.27.7",
|
||||||
"@esbuild/linux-riscv64": "0.27.3",
|
"@esbuild/linux-riscv64": "0.27.7",
|
||||||
"@esbuild/linux-s390x": "0.27.3",
|
"@esbuild/linux-s390x": "0.27.7",
|
||||||
"@esbuild/linux-x64": "0.27.3",
|
"@esbuild/linux-x64": "0.27.7",
|
||||||
"@esbuild/netbsd-arm64": "0.27.3",
|
"@esbuild/netbsd-arm64": "0.27.7",
|
||||||
"@esbuild/netbsd-x64": "0.27.3",
|
"@esbuild/netbsd-x64": "0.27.7",
|
||||||
"@esbuild/openbsd-arm64": "0.27.3",
|
"@esbuild/openbsd-arm64": "0.27.7",
|
||||||
"@esbuild/openbsd-x64": "0.27.3",
|
"@esbuild/openbsd-x64": "0.27.7",
|
||||||
"@esbuild/openharmony-arm64": "0.27.3",
|
"@esbuild/openharmony-arm64": "0.27.7",
|
||||||
"@esbuild/sunos-x64": "0.27.3",
|
"@esbuild/sunos-x64": "0.27.7",
|
||||||
"@esbuild/win32-arm64": "0.27.3",
|
"@esbuild/win32-arm64": "0.27.7",
|
||||||
"@esbuild/win32-ia32": "0.27.3",
|
"@esbuild/win32-ia32": "0.27.7",
|
||||||
"@esbuild/win32-x64": "0.27.3"
|
"@esbuild/win32-x64": "0.27.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/fdir": {
|
"node_modules/vite/node_modules/fdir": {
|
||||||
@@ -11905,9 +11909,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/picomatch": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.10.0",
|
"version": "1.14.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.2",
|
||||||
"vite-tsconfig-paths": "6.1.1"
|
"vite-tsconfig-paths": "6.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,11 @@ const config: Config = {
|
|||||||
min: { value: 'min-content' },
|
min: { value: 'min-content' },
|
||||||
max: { value: 'max-content' },
|
max: { value: 'max-content' },
|
||||||
fit: { value: 'fit-content' },
|
fit: { value: 'fit-content' },
|
||||||
|
// room layout
|
||||||
|
'room-side-panel': { value: '360px' },
|
||||||
|
'room-side-panel-margin': { value: '1.5rem' },
|
||||||
|
'room-control-bar': { value: '80px' },
|
||||||
|
'room-reaction-toolbar-height': { value: '42px' },
|
||||||
},
|
},
|
||||||
spacing,
|
spacing,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -15,7 +15,19 @@ export const fetchApi = async <T = Record<string, unknown>>(
|
|||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const result = await response.json()
|
|
||||||
|
let result: T
|
||||||
|
if (response.status === 204) {
|
||||||
|
result = undefined as T
|
||||||
|
} else {
|
||||||
|
const contentType = response.headers.get('content-type') ?? ''
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
result = undefined as T
|
||||||
|
} else {
|
||||||
|
result = (await response.json()) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(response.status, result)
|
throw new ApiError(response.status, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export const keys = {
|
|||||||
requestEntry: 'requestEntry',
|
requestEntry: 'requestEntry',
|
||||||
waitingParticipants: 'waitingParticipants',
|
waitingParticipants: 'waitingParticipants',
|
||||||
roomCreationCallback: 'roomCreationCallback',
|
roomCreationCallback: 'roomCreationCallback',
|
||||||
|
files: 'files',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ export interface ApiConfig {
|
|||||||
expiration_days?: number
|
expiration_days?: number
|
||||||
max_duration?: number
|
max_duration?: number
|
||||||
}
|
}
|
||||||
|
background_image: {
|
||||||
|
upload_is_enabled: boolean
|
||||||
|
max_size: number
|
||||||
|
max_count_by_user: number
|
||||||
|
allowed_extensions: string[]
|
||||||
|
allowed_mimetypes: string[]
|
||||||
|
}
|
||||||
subtitle: {
|
subtitle: {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export enum FeatureFlags {
|
|||||||
faceLandmarks = 'face-landmarks',
|
faceLandmarks = 'face-landmarks',
|
||||||
noiseReduction = 'noise-reduction',
|
noiseReduction = 'noise-reduction',
|
||||||
subtitles = 'subtitles',
|
subtitles = 'subtitles',
|
||||||
|
candidatePolling = 'candidate-polling',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { ApiFileItem } from '@/features/files/api/types.ts'
|
||||||
|
import { keys } from '@/api/queryKeys.ts'
|
||||||
|
import { queryClient } from '@/api/queryClient.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file, using XHR so we can report on progress through a handler.
|
||||||
|
*
|
||||||
|
* @param url The URL to PUT the file to.
|
||||||
|
* @param file The file to upload.
|
||||||
|
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
|
||||||
|
*/
|
||||||
|
export const uploadFile = (
|
||||||
|
url: string,
|
||||||
|
file: File,
|
||||||
|
progressHandler: (progress: number) => void
|
||||||
|
) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
xhr.open('PUT', url)
|
||||||
|
xhr.setRequestHeader('X-amz-acl', 'private')
|
||||||
|
xhr.setRequestHeader('Content-Type', file.type)
|
||||||
|
|
||||||
|
xhr.addEventListener('error', reject)
|
||||||
|
xhr.addEventListener('abort', reject)
|
||||||
|
|
||||||
|
xhr.addEventListener('readystatechange', () => {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
// Make sure to always set the progress to 100% when the upload is done.
|
||||||
|
// Because 'progress' event listener is not called when the file size is 0.
|
||||||
|
progressHandler(100)
|
||||||
|
return resolve(true)
|
||||||
|
}
|
||||||
|
reject(new Error(`Failed to perform the upload on ${url}.`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (progressEvent) => {
|
||||||
|
if (progressEvent.lengthComputable) {
|
||||||
|
progressHandler(
|
||||||
|
Math.floor((progressEvent.loaded / progressEvent.total) * 100)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
xhr.send(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously creates a new file and uploads it to the server.
|
||||||
|
*
|
||||||
|
* @param {object} params - The parameters for the file creation and upload process.
|
||||||
|
* @param {File} params.file - The file object to be uploaded.
|
||||||
|
* @param {function} params.onProgress - A callback function that receives the upload progress as a number (0 to 100).
|
||||||
|
* @returns {Promise<ApiFileItem>} A promise that resolves when the file has been successfully uploaded and the server process is completed.
|
||||||
|
*/
|
||||||
|
export const createFile = async ({
|
||||||
|
file,
|
||||||
|
onProgress,
|
||||||
|
}: {
|
||||||
|
file: File
|
||||||
|
onProgress: (progress: number) => void
|
||||||
|
}): Promise<ApiFileItem> => {
|
||||||
|
const res = await fetchApi<ApiFileItem>(`/files/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ filename: file.name, type: 'background_image' }),
|
||||||
|
})
|
||||||
|
if (res.upload_state !== 'pending') {
|
||||||
|
throw new Error('State should be pending right after creation')
|
||||||
|
}
|
||||||
|
const policy = res.policy
|
||||||
|
await uploadFile(policy, file, onProgress)
|
||||||
|
const createdFile = await fetchApi<ApiFileItem>(
|
||||||
|
`/files/${res.id}/upload-ended/`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// We invalidate the files query to make sure the new file is immediately available.
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [keys.files],
|
||||||
|
})
|
||||||
|
return createdFile
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreateFile = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createFile,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { keys } from '@/api/queryKeys.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a file specified by its unique identifier.
|
||||||
|
*
|
||||||
|
* @param {Object} params - The parameters required for deleting the file.
|
||||||
|
* @param {string} params.fileId - The unique identifier of the file to be deleted.
|
||||||
|
* @returns {Promise<void>} A promise that resolves when the file is successfully deleted.
|
||||||
|
*/
|
||||||
|
export const deleteFile = async ({
|
||||||
|
fileId,
|
||||||
|
}: {
|
||||||
|
fileId: string
|
||||||
|
}): Promise<void> => {
|
||||||
|
await fetchApi<void>(`/files/${fileId}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeleteFile = () => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteFile,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [keys.files],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
|
import { keys } from '@/api/queryKeys'
|
||||||
|
import {
|
||||||
|
ApiFileItem,
|
||||||
|
ApiFileType,
|
||||||
|
ApiFileUploadState,
|
||||||
|
} from '@/features/files/api/types.ts'
|
||||||
|
import { useUser } from '@/features/auth'
|
||||||
|
import { useConfig } from '@/api/useConfig.ts'
|
||||||
|
|
||||||
|
type ListFilesResponse = {
|
||||||
|
count: number
|
||||||
|
next: string | null
|
||||||
|
previous: string | null
|
||||||
|
results: ApiFileItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListFilesFilters = {
|
||||||
|
is_creator_me?: boolean
|
||||||
|
type?: ApiFileType
|
||||||
|
upload_state?: ApiFileUploadState
|
||||||
|
is_deleted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListFilesParams = {
|
||||||
|
filters?: ListFilesFilters
|
||||||
|
pagination: {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listMyFiles = async ({
|
||||||
|
filters = {},
|
||||||
|
pagination: { page, pageSize },
|
||||||
|
}: ListFilesParams): Promise<ListFilesResponse> => {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
query.append('page', page.toString())
|
||||||
|
query.append('page_size', pageSize.toString())
|
||||||
|
if (filters?.is_creator_me ?? true) {
|
||||||
|
query.append('is_creator_me', 'true')
|
||||||
|
}
|
||||||
|
if (filters?.type) {
|
||||||
|
query.append('type', filters.type)
|
||||||
|
}
|
||||||
|
if (filters?.upload_state) {
|
||||||
|
query.append('upload_state', filters.upload_state)
|
||||||
|
}
|
||||||
|
if (typeof filters?.is_deleted === 'boolean') {
|
||||||
|
query.append('is_deleted', filters.is_deleted ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchApi<ListFilesResponse>(`/files?${query.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useListMyFiles = (params: Parameters<typeof listMyFiles>[0]) => {
|
||||||
|
const { isLoggedIn } = useUser()
|
||||||
|
const { data: appConfig } = useConfig()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [keys.files, params],
|
||||||
|
queryFn: () => listMyFiles(params),
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
enabled:
|
||||||
|
isLoggedIn && appConfig?.background_image?.upload_is_enabled === true,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type ApiFileCreator = {
|
||||||
|
id: string // UUID
|
||||||
|
full_name: string | null
|
||||||
|
short_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApiFileType = 'background_image'
|
||||||
|
export type ApiFileUploadState = 'pending' | 'ready'
|
||||||
|
|
||||||
|
export type ApiFileItem = {
|
||||||
|
id: string // UUID
|
||||||
|
created_at: string // ISO datetime string
|
||||||
|
updated_at: string // ISO datetime string
|
||||||
|
title: string
|
||||||
|
type: ApiFileType
|
||||||
|
creator: ApiFileCreator
|
||||||
|
deleted_at: string | null
|
||||||
|
hard_deleted_at: string | null
|
||||||
|
filename: string
|
||||||
|
upload_state: ApiFileUploadState
|
||||||
|
mimetype: string // e.g. "image/png"
|
||||||
|
size: number // file size in bytes
|
||||||
|
description: string | null
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
upload_state: 'ready'
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
upload_state: 'pending'
|
||||||
|
policy: string
|
||||||
|
url: null
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { cva, RecipeVariantProps } from '@/styled-system/css'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const controlBarRegion = cva({
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: '1 1 33%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.65rem',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
mobile: {
|
||||||
|
true: {
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '330px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
mobile: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ControlBarRegionProps = React.HTMLAttributes<HTMLDivElement> &
|
||||||
|
RecipeVariantProps<typeof controlBarRegion>
|
||||||
|
|
||||||
|
export function ControlBarRegion({
|
||||||
|
children,
|
||||||
|
mobile,
|
||||||
|
...props
|
||||||
|
}: ControlBarRegionProps) {
|
||||||
|
const { t } = useTranslation('rooms')
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label={t('controls.region')}
|
||||||
|
className={controlBarRegion({ mobile })}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-3
@@ -7,9 +7,9 @@ import {
|
|||||||
useSwipe,
|
useSwipe,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { mergeProps } from '@/utils/mergeProps'
|
import { mergeProps } from '@/utils/mergeProps'
|
||||||
import { PaginationIndicator } from '../controls/PaginationIndicator'
|
import { PaginationIndicator } from './PaginationIndicator'
|
||||||
import { useGridLayout } from '../../hooks/useGridLayout'
|
import { useGridLayout } from '../hooks/useGridLayout'
|
||||||
import { PaginationControl } from '../controls/PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// RoomContentArea.tsx
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { cva } from '@/styled-system/css'
|
||||||
|
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
||||||
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||||
|
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||||
|
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||||
|
|
||||||
|
const RoomViewport = styled(
|
||||||
|
'div',
|
||||||
|
cva({
|
||||||
|
base: {
|
||||||
|
position: 'absolute',
|
||||||
|
maxHeight: '100%',
|
||||||
|
transition:
|
||||||
|
'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms, padding .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
isSidePanelOpen: {
|
||||||
|
true: {
|
||||||
|
inset: `var(--lk-grid-gap) calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin) * 2) calc(var(--sizes-room-control-bar)) 16px`,
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
inset: `var(--lk-grid-gap) var(--lk-grid-gap) calc(var(--sizes-room-control-bar))`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isReactionToolbarOpen: {
|
||||||
|
true: {
|
||||||
|
paddingBottom:
|
||||||
|
'calc(var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))',
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
paddingBottom: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const TrackAreaContainer = styled(
|
||||||
|
'div',
|
||||||
|
cva({
|
||||||
|
base: {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
transition: 'height .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
areSubtitlesOpen: {
|
||||||
|
true: {
|
||||||
|
height: 'calc(100% - 12rem)',
|
||||||
|
},
|
||||||
|
false: {
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
interface RoomContentAreaProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoomContentArea({ children }: RoomContentAreaProps) {
|
||||||
|
const { isSidePanelOpen } = useSidePanel()
|
||||||
|
const { areSubtitlesOpen } = useSubtitles()
|
||||||
|
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RoomViewport
|
||||||
|
isSidePanelOpen={isSidePanelOpen}
|
||||||
|
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||||
|
>
|
||||||
|
<TrackAreaContainer areSubtitlesOpen={areSubtitlesOpen}>
|
||||||
|
{children}
|
||||||
|
</TrackAreaContainer>
|
||||||
|
<Subtitles />
|
||||||
|
<MainNotificationToast />
|
||||||
|
</RoomViewport>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||||
@@ -10,17 +10,11 @@ import { decodeNotificationDataReceived } from './utils'
|
|||||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||||
import {
|
|
||||||
Emoji,
|
|
||||||
Reaction,
|
|
||||||
} from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
|
||||||
import {
|
|
||||||
ANIMATION_DURATION,
|
|
||||||
ReactionPortals,
|
|
||||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
import { layoutStore } from '@/stores/layout'
|
||||||
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { Emoji } from '@/features/reactions/types'
|
||||||
|
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
||||||
|
|
||||||
export const MainNotificationToast = () => {
|
export const MainNotificationToast = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
@@ -28,8 +22,7 @@ export const MainNotificationToast = () => {
|
|||||||
const { t } = useTranslation('notifications')
|
const { t } = useTranslation('notifications')
|
||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const [reactions, setReactions] = useState<Reaction[]>([])
|
const { appendReaction } = useReactions()
|
||||||
const instanceIdRef = useRef(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChatMessage = (
|
const handleChatMessage = (
|
||||||
@@ -62,21 +55,13 @@ export const MainNotificationToast = () => {
|
|||||||
}
|
}
|
||||||
}, [room, triggerNotificationSound, announce, t])
|
}, [room, triggerNotificationSound, announce, t])
|
||||||
|
|
||||||
const handleEmoji = (emoji: string, participant: Participant) => {
|
const handleEmoji = useCallback(
|
||||||
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
|
(emoji: string, participant: Participant) => {
|
||||||
const id = instanceIdRef.current++
|
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
|
||||||
setReactions((prev) => [
|
appendReaction(emoji as Emoji, participant)
|
||||||
...prev,
|
},
|
||||||
{
|
[appendReaction]
|
||||||
id,
|
)
|
||||||
emoji,
|
|
||||||
participant,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setTimeout(() => {
|
|
||||||
setReactions((prev) => prev.filter((instance) => instance.id !== id))
|
|
||||||
}, ANIMATION_DURATION)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDataReceived = (
|
const handleDataReceived = (
|
||||||
@@ -149,7 +134,7 @@ export const MainNotificationToast = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||||
}
|
}
|
||||||
}, [room])
|
}, [room, handleEmoji])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const showJoinNotification = (participant: Participant) => {
|
const showJoinNotification = (participant: Participant) => {
|
||||||
@@ -252,7 +237,6 @@ export const MainNotificationToast = () => {
|
|||||||
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
<WaitingParticipantNotification />
|
<WaitingParticipantNotification />
|
||||||
<ReactionPortals reactions={reactions} />
|
|
||||||
</Div>
|
</Div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-53
@@ -2,19 +2,17 @@ import { createPortal } from 'react-dom'
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
|
||||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
|
||||||
import { accessibilityStore } from '@/stores/accessibility'
|
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { reactionsStore } from '@/stores/reactions'
|
||||||
|
import { useAnnounceReaction } from '../hooks/useAnnounceReaction'
|
||||||
export const ANIMATION_DURATION = 3000
|
import { Reaction } from '../types'
|
||||||
export const ANIMATION_DISTANCE = 300
|
import {
|
||||||
export const FADE_OUT_THRESHOLD = 0.7
|
ANIMATION_DISTANCE,
|
||||||
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
|
ANIMATION_DURATION,
|
||||||
export const INITIAL_POSITION = 200
|
FADE_OUT_THRESHOLD,
|
||||||
|
INITIAL_POSITION,
|
||||||
|
REACTION_SPAWN_WIDTH_RATIO,
|
||||||
|
} from '../constants'
|
||||||
|
|
||||||
interface FloatingReactionProps {
|
interface FloatingReactionProps {
|
||||||
emoji: string
|
emoji: string
|
||||||
@@ -79,7 +77,7 @@ export function FloatingReaction({
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/assets/reactions/${emoji}.png`}
|
src={`/assets/reactions/${emoji}.png`}
|
||||||
alt={''}
|
alt=""
|
||||||
className={css({
|
className={css({
|
||||||
height: '50px',
|
height: '50px',
|
||||||
})}
|
})}
|
||||||
@@ -116,14 +114,7 @@ export function FloatingReaction({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReactionPortal({
|
const ReactionPortal = ({ reaction }: { reaction: Reaction }) => {
|
||||||
emoji,
|
|
||||||
participant,
|
|
||||||
}: {
|
|
||||||
emoji: string
|
|
||||||
participant: Participant
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
|
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
|
||||||
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -138,51 +129,27 @@ export function ReactionPortal({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FloatingReaction
|
<FloatingReaction
|
||||||
emoji={emoji}
|
emoji={reaction.emoji}
|
||||||
speed={speed}
|
speed={speed}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
name={participant?.isLocal ? t('you') : participant.name}
|
name={reaction.participantName}
|
||||||
isLocal={participant?.isLocal}
|
isLocal={reaction.isLocal}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
export const ReactionPortals = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
const { reactions } = useSnapshot(reactionsStore)
|
||||||
const { announceReactions } = useSnapshot(accessibilityStore)
|
const latestReaction = reactions.at(-1)
|
||||||
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
const latestReaction =
|
useAnnounceReaction(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{reactions.map((instance) => (
|
{reactions.map((instance) => (
|
||||||
<ReactionPortal
|
<ReactionPortal key={instance.id} reaction={instance} />
|
||||||
key={instance.id}
|
|
||||||
emoji={instance.emoji}
|
|
||||||
participant={instance.participant}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiEmotionLine } from '@remixicon/react'
|
||||||
|
import { ToggleButton } from '@/primitives'
|
||||||
|
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import { REACTIONS_TOOLBAR_ID } from '../constants'
|
||||||
|
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
||||||
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
|
||||||
|
const focusReactionsToolbar = () => {
|
||||||
|
document
|
||||||
|
.getElementById(REACTIONS_TOOLBAR_ID)
|
||||||
|
?.querySelector<HTMLElement>('button')
|
||||||
|
?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReactionsToggle = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
|
|
||||||
|
const { isOpen, toggle } = useReactionsToolbar()
|
||||||
|
|
||||||
|
const handleShortcut = useCallback(() => {
|
||||||
|
if (layoutStore.showReactionsToolbar) {
|
||||||
|
focusReactionsToolbar()
|
||||||
|
} else {
|
||||||
|
layoutStore.showReactionsToolbar = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'reaction',
|
||||||
|
handler: handleShortcut,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleButton
|
||||||
|
id="reactions-toggle"
|
||||||
|
data-attr="reactions-toggle"
|
||||||
|
square
|
||||||
|
variant="primaryDark"
|
||||||
|
aria-label={t('button')}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
tooltip={t('button')}
|
||||||
|
isSelected={isOpen}
|
||||||
|
onChange={toggle}
|
||||||
|
>
|
||||||
|
<RiEmotionLine />
|
||||||
|
</ToggleButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Emoji } from '../../types'
|
||||||
|
import { useReactions } from '../../hooks/useReactions'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
|
||||||
|
export const ReactionButton = ({ emoji }: { emoji: Emoji }) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
|
const { sendReaction } = useReactions()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onPress={() => sendReaction(emoji)}
|
||||||
|
aria-label={t(`emojis.${emoji}`)}
|
||||||
|
variant="primaryTextDark"
|
||||||
|
size="sm"
|
||||||
|
round
|
||||||
|
data-attr={`send-reaction-${emoji}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/assets/reactions/${emoji}.png`}
|
||||||
|
alt=""
|
||||||
|
className={css({
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { FocusScope, useFocusManager } from '@react-aria/focus'
|
||||||
|
import { REACTIONS_TOOLBAR_ID } from '../../constants'
|
||||||
|
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||||
|
import { ReactionButton } from './ReactionButton'
|
||||||
|
import { Emoji } from '../../types'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
import { getFirstControlBarFocusable } from '@/utils/dom'
|
||||||
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const Container = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 'var(--sizes-room-control-bar)',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const StyledStrip = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.2rem',
|
||||||
|
borderRadius: '21px',
|
||||||
|
padding: '0.15rem',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'translateY(3.25rem)',
|
||||||
|
transition: 'opacity, transform',
|
||||||
|
transitionDuration: '0.5s',
|
||||||
|
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
isVisible: {
|
||||||
|
true: {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
desktopOffset: {
|
||||||
|
true: {
|
||||||
|
// Ideally this value should be calculated dynamically in JavaScript to keep
|
||||||
|
// the reaction toolbar perfectly centered relative to the reaction toggle.
|
||||||
|
// However, for simplicity and to follow a pragmatic 80/20 approach,
|
||||||
|
// this value is currently hardcoded in CSS.
|
||||||
|
marginRight: '30px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const Strip = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { isOpen } = useReactionsToolbar()
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
// defer one frame so the browser paints opacity:0 first
|
||||||
|
const id = requestAnimationFrame(() => setIsVisible(true))
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
} else {
|
||||||
|
setIsVisible(false)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledStrip
|
||||||
|
ref={ref}
|
||||||
|
aria-hidden={!isOpen}
|
||||||
|
isVisible={isVisible}
|
||||||
|
desktopOffset={!isMobile}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</StyledStrip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
|
const focusManager = useFocusManager()
|
||||||
|
|
||||||
|
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||||
|
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
|
||||||
|
if (comingFromOutside) {
|
||||||
|
focusManager?.focusFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
focusManager?.focusNext({ wrap: true })
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
focusManager?.focusPrevious({ wrap: true })
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
document.getElementById('reactions-toggle')?.focus()
|
||||||
|
layoutStore.showReactionsToolbar = false
|
||||||
|
break
|
||||||
|
case 'Tab':
|
||||||
|
if (!e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
getFirstControlBarFocusable('control-bar')?.focus()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={REACTIONS_TOOLBAR_ID}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={t('toolbar')}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onFocus={onFocus}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReactionsToolbar = () => {
|
||||||
|
const { isOpen } = useReactionsToolbar()
|
||||||
|
const shouldMount = useDelayUnmount(isOpen, 300)
|
||||||
|
|
||||||
|
if (!shouldMount) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
|
||||||
|
<FocusScope autoFocus>
|
||||||
|
<KeyboardNavigation>
|
||||||
|
<Strip>
|
||||||
|
{Object.values(Emoji).map((emoji) => (
|
||||||
|
<ReactionButton key={emoji} emoji={emoji} />
|
||||||
|
))}
|
||||||
|
</Strip>
|
||||||
|
</KeyboardNavigation>
|
||||||
|
</FocusScope>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const REACTIONS_TOOLBAR_ID = 'reactions-toolbar'
|
||||||
|
export const ANIMATION_DURATION = 3000
|
||||||
|
export const ANIMATION_DISTANCE = 300
|
||||||
|
export const FADE_OUT_THRESHOLD = 0.7
|
||||||
|
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
|
||||||
|
export const INITIAL_POSITION = 200
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { accessibilityStore } from '@/stores/accessibility'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
|
import { getEmojiLabel } from '../utils'
|
||||||
|
import { Reaction } from '../types'
|
||||||
|
|
||||||
|
export const useAnnounceReaction = (latestReaction: Reaction | undefined) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
|
const { announceReactions } = useSnapshot(accessibilityStore)
|
||||||
|
const [lastAnnouncedId, setLastAnnouncedId] = useState<string | null>(null)
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!announceReactions || !latestReaction) return
|
||||||
|
if (latestReaction.id === lastAnnouncedId) return
|
||||||
|
|
||||||
|
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
|
||||||
|
const participantName = latestReaction.participantName
|
||||||
|
|
||||||
|
announce(t('announce', { name: participantName, emoji: emojiLabel }))
|
||||||
|
setLastAnnouncedId(latestReaction.id)
|
||||||
|
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { reactionsStore } from '@/stores/reactions'
|
||||||
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
import { useNotifyParticipants } from '@/features/notifications'
|
||||||
|
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { Emoji } from '../types'
|
||||||
|
import { ANIMATION_DURATION } from '../constants'
|
||||||
|
|
||||||
|
export const useReactions = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
|
const { notifyParticipants } = useNotifyParticipants()
|
||||||
|
|
||||||
|
const appendReaction = useCallback(
|
||||||
|
(emoji: Emoji, participant?: Participant) => {
|
||||||
|
const newReaction = {
|
||||||
|
id: `${emoji}-${Date.now()}-${Math.random()}`,
|
||||||
|
emoji,
|
||||||
|
participantName: participant
|
||||||
|
? participant.name || participant.identity
|
||||||
|
: t('you'),
|
||||||
|
isLocal: !participant,
|
||||||
|
}
|
||||||
|
|
||||||
|
reactionsStore.reactions.push(newReaction)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const index = reactionsStore.reactions.findIndex(
|
||||||
|
(r) => r.id === newReaction.id
|
||||||
|
)
|
||||||
|
if (index !== -1) reactionsStore.reactions.splice(index, 1)
|
||||||
|
}, ANIMATION_DURATION)
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const sendReaction = async (emoji: Emoji) => {
|
||||||
|
appendReaction(emoji)
|
||||||
|
await notifyParticipants({
|
||||||
|
type: NotificationType.ReactionReceived,
|
||||||
|
additionalData: { data: { emoji } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSendReaction = useRateLimiter({
|
||||||
|
callback: sendReaction,
|
||||||
|
maxCalls: 10,
|
||||||
|
windowMs: 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendReaction: debouncedSendReaction,
|
||||||
|
appendReaction,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
|
||||||
|
export const useReactionsToolbar = () => {
|
||||||
|
const layoutSnap = useSnapshot(layoutStore)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen: layoutSnap.showReactionsToolbar,
|
||||||
|
toggle: () => {
|
||||||
|
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export enum Emoji {
|
||||||
|
THUMBS_UP = 'thumbs-up',
|
||||||
|
THUMBS_DOWN = 'thumbs-down',
|
||||||
|
CLAP = 'clapping-hands',
|
||||||
|
HEART = 'red-heart',
|
||||||
|
LAUGHING = 'face-with-tears-of-joy',
|
||||||
|
SURPRISED = 'face-with-open-mouth',
|
||||||
|
CELEBRATION = 'party-popper',
|
||||||
|
PLEASE = 'folded-hands',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Reaction {
|
||||||
|
id: string
|
||||||
|
emoji: Emoji
|
||||||
|
participantName: string
|
||||||
|
isLocal: boolean
|
||||||
|
}
|
||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Emoji } from './types'
|
||||||
|
|
||||||
export const getEmojiLabel = (
|
export const getEmojiLabel = (
|
||||||
emoji: string,
|
emoji: string | Emoji,
|
||||||
t: ReturnType<typeof useTranslation>['t']
|
t: ReturnType<typeof useTranslation>['t']
|
||||||
) => {
|
) => {
|
||||||
const emojiLabels: Record<string, string> = {
|
const emojiLabels: Record<string, string> = {
|
||||||
@@ -3,6 +3,8 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { Center, VStack } from '@/styled-system/jsx'
|
import { Center, VStack } from '@/styled-system/jsx'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useTitle } from 'hoofd'
|
||||||
|
import { useMemo } from 'react'
|
||||||
import { mediaUrl } from '@/api/mediaUrl'
|
import { mediaUrl } from '@/api/mediaUrl'
|
||||||
import { UserAware, useUser } from '@/features/auth'
|
import { UserAware, useUser } from '@/features/auth'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
@@ -14,6 +16,8 @@ import { fetchRecording } from '../api/fetchRecording'
|
|||||||
import { RecordingStatus } from '@/features/recording'
|
import { RecordingStatus } from '@/features/recording'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
|
const APP_TITLE = import.meta.env.VITE_APP_TITLE ?? ''
|
||||||
|
|
||||||
const BetaBadge = () => (
|
const BetaBadge = () => (
|
||||||
<span
|
<span
|
||||||
className={css({
|
className={css({
|
||||||
@@ -49,6 +53,21 @@ export const RecordingDownload = () => {
|
|||||||
enabled: !!recordingId,
|
enabled: !!recordingId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isSaved =
|
||||||
|
data?.status === RecordingStatus.Saved ||
|
||||||
|
data?.status === RecordingStatus.NotificationSucceed ||
|
||||||
|
data?.status === RecordingStatus.FailedToStop
|
||||||
|
|
||||||
|
const pageTitle = useMemo(() => {
|
||||||
|
if (isError) return `${APP_TITLE} - ${t('error.title')}`
|
||||||
|
if (data && !isSaved) return `${APP_TITLE} - ${t('unsaved.title')}`
|
||||||
|
if (data?.is_expired) return `${APP_TITLE} - ${t('expired.title')}`
|
||||||
|
if (data && isSaved) return `${APP_TITLE} - ${t('success.title')}`
|
||||||
|
return APP_TITLE
|
||||||
|
}, [isError, data, isSaved, t])
|
||||||
|
|
||||||
|
useTitle(pageTitle)
|
||||||
|
|
||||||
if (isLoggedIn === undefined || isAuthLoading) {
|
if (isLoggedIn === undefined || isAuthLoading) {
|
||||||
return <LoadingScreen />
|
return <LoadingScreen />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
|
||||||
|
export const useRenameParticipant = () => {
|
||||||
|
const data = useRoomData()
|
||||||
|
|
||||||
|
const renameParticipant = async (name: string) => {
|
||||||
|
if (!data?.id) {
|
||||||
|
throw new Error('Room id is not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = data?.livekit?.token
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('LiveKit token is not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchApi(`rooms/${data.id}/rename/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renameParticipant }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
|
||||||
|
export const useRaiseHand = () => {
|
||||||
|
const data = useRoomData()
|
||||||
|
|
||||||
|
const raiseHand = async (raised: boolean) => {
|
||||||
|
if (!data?.id) {
|
||||||
|
throw new Error('Room id is not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = data?.livekit?.token
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('LiveKit token is not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
raised,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { raiseHand }
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ 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'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -44,6 +46,8 @@ export const Conference = ({
|
|||||||
const posthog = usePostHog()
|
const posthog = usePostHog()
|
||||||
const { data: apiConfig } = useConfig()
|
const { data: apiConfig } = useConfig()
|
||||||
|
|
||||||
|
const connectionObserverSnap = useSnapshot(connectionObserverStore)
|
||||||
|
|
||||||
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||||
userChoices: LocalUserChoices
|
userChoices: LocalUserChoices
|
||||||
}
|
}
|
||||||
@@ -215,8 +219,8 @@ export const Conference = ({
|
|||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={
|
video={
|
||||||
userConfig.videoEnabled && {
|
userConfig.videoEnabled && {
|
||||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
processor: BackgroundProcessorFactory.fromProcessorConfig(
|
||||||
userConfig.processorSerialized
|
userConfig.processorConfig
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,9 +232,34 @@ export const Conference = ({
|
|||||||
posthog.captureException(e)
|
posthog.captureException(e)
|
||||||
}}
|
}}
|
||||||
onDisconnected={(e) => {
|
onDisconnected={(e) => {
|
||||||
|
const metadata = {
|
||||||
|
room_id: roomId,
|
||||||
|
pc_publisher: connectionObserverSnap.publisher && {
|
||||||
|
...connectionObserverSnap.publisher,
|
||||||
|
},
|
||||||
|
pc_subscriber: connectionObserverSnap.subscriber && {
|
||||||
|
...connectionObserverSnap.subscriber,
|
||||||
|
},
|
||||||
|
pc_publisher_changes_count:
|
||||||
|
connectionObserverSnap.publisherChangesCount,
|
||||||
|
pc_subscriber_changes_count:
|
||||||
|
connectionObserverSnap.subscriberChangesCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionObserverStore.publisher = null
|
||||||
|
connectionObserverStore.publisherChangesCount = 0
|
||||||
|
connectionObserverStore.subscriber = null
|
||||||
|
connectionObserverStore.subscriberChangesCount = 0
|
||||||
|
|
||||||
switch (e) {
|
switch (e) {
|
||||||
case DisconnectReason.CLIENT_INITIATED:
|
case DisconnectReason.CLIENT_INITIATED:
|
||||||
navigateTo('feedback')
|
navigateTo(
|
||||||
|
'feedback',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
state: { ...metadata },
|
||||||
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||||
@@ -238,7 +267,10 @@ export const Conference = ({
|
|||||||
'feedback',
|
'feedback',
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
state: { reason: e },
|
state: {
|
||||||
|
reason: e,
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import { css } from '@/styled-system/css'
|
|||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
createLocalVideoTrack,
|
|
||||||
createLocalAudioTrack,
|
createLocalAudioTrack,
|
||||||
|
createLocalVideoTrack,
|
||||||
LocalAudioTrack,
|
LocalAudioTrack,
|
||||||
LocalVideoTrack,
|
LocalVideoTrack,
|
||||||
Track,
|
Track,
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { H } from '@/primitives/H'
|
import { H } from '@/primitives/H'
|
||||||
import { Field } from '@/primitives/Field'
|
import { Field } from '@/primitives/Field'
|
||||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
import { Button, Dialog, Form, Text } from '@/primitives'
|
||||||
import { VStack } from '@/styled-system/jsx'
|
import { VStack } from '@/styled-system/jsx'
|
||||||
import { Heading } from 'react-aria-components'
|
import { Heading } from 'react-aria-components'
|
||||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||||
@@ -44,8 +44,7 @@ const onError = (e: Error) => console.error('ERROR', e)
|
|||||||
|
|
||||||
const Effects = ({
|
const Effects = ({
|
||||||
videoTrack,
|
videoTrack,
|
||||||
onSubmit,
|
}: Pick<EffectsConfigurationProps, 'videoTrack'>) => {
|
||||||
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
const openDialog = () => setIsDialogOpen(true)
|
const openDialog = () => setIsDialogOpen(true)
|
||||||
@@ -81,7 +80,7 @@ const Effects = ({
|
|||||||
>
|
>
|
||||||
{t('subTitle')}
|
{t('subTitle')}
|
||||||
</Text>
|
</Text>
|
||||||
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
<EffectsConfiguration videoTrack={videoTrack} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<Button
|
<Button
|
||||||
variant="whiteCircle"
|
variant="whiteCircle"
|
||||||
@@ -111,7 +110,7 @@ export const Join = ({
|
|||||||
audioDeviceId,
|
audioDeviceId,
|
||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorSerialized,
|
processorConfig,
|
||||||
username,
|
username,
|
||||||
},
|
},
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
@@ -120,7 +119,6 @@ export const Join = ({
|
|||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveVideoInputDeviceId,
|
saveVideoInputDeviceId,
|
||||||
saveUsername,
|
saveUsername,
|
||||||
saveProcessorSerialized,
|
|
||||||
} = usePersistentUserChoices()
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||||
@@ -132,7 +130,7 @@ export const Join = ({
|
|||||||
audioDeviceId,
|
audioDeviceId,
|
||||||
audioOutputDeviceId,
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorSerialized,
|
processorConfig,
|
||||||
username,
|
username,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,8 +144,8 @@ export const Join = ({
|
|||||||
video: !!initialUserChoices.current &&
|
video: !!initialUserChoices.current &&
|
||||||
initialUserChoices.current?.videoEnabled && {
|
initialUserChoices.current?.videoEnabled && {
|
||||||
deviceId: initialUserChoices.current.videoDeviceId,
|
deviceId: initialUserChoices.current.videoDeviceId,
|
||||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
processor: BackgroundProcessorFactory.fromProcessorConfig(
|
||||||
initialUserChoices.current.processorSerialized
|
initialUserChoices.current.processorConfig
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -186,9 +184,7 @@ export const Join = ({
|
|||||||
const track = await createLocalVideoTrack({
|
const track = await createLocalVideoTrack({
|
||||||
deviceId: { exact: videoDeviceId },
|
deviceId: { exact: videoDeviceId },
|
||||||
processor:
|
processor:
|
||||||
BackgroundProcessorFactory.deserializeProcessor(
|
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||||
processorSerialized
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
setDynamicVideoTrack(track)
|
setDynamicVideoTrack(track)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -207,7 +203,7 @@ export const Join = ({
|
|||||||
}, [
|
}, [
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorSerialized,
|
processorConfig,
|
||||||
previewVideoTrack,
|
previewVideoTrack,
|
||||||
dynamicVideoTrack,
|
dynamicVideoTrack,
|
||||||
])
|
])
|
||||||
@@ -690,12 +686,7 @@ export const Join = ({
|
|||||||
zIndex: '1',
|
zIndex: '1',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Effects
|
<Effects videoTrack={videoTrack} />
|
||||||
videoTrack={videoTrack}
|
|
||||||
onSubmit={(processor) =>
|
|
||||||
saveProcessorSerialized(processor?.serialize())
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { usePostHog } from 'posthog-js/react'
|
|||||||
import type { PostHog } from 'posthog-js'
|
import type { PostHog } from 'posthog-js'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||||
|
import { CandidateInfo } from '@/stores/connectionObserver'
|
||||||
|
|
||||||
const Card = styled('div', {
|
const Card = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
@@ -73,9 +74,11 @@ const labelRecipe = cva({
|
|||||||
const OpenFeedback = ({
|
const OpenFeedback = ({
|
||||||
posthog,
|
posthog,
|
||||||
onNext,
|
onNext,
|
||||||
|
metadata,
|
||||||
}: {
|
}: {
|
||||||
posthog: PostHog
|
posthog: PostHog
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'openFeedback' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'openFeedback' })
|
||||||
const [feedback, setFeedback] = useState('')
|
const [feedback, setFeedback] = useState('')
|
||||||
@@ -87,9 +90,9 @@ const OpenFeedback = ({
|
|||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
try {
|
try {
|
||||||
posthog.capture('survey sent', {
|
posthog.capture('open-feedback', {
|
||||||
$survey_id: '01933c5a-5a1d-0000-ada8-e39f5918c2d4',
|
feedback,
|
||||||
$survey_response: feedback,
|
...metadata,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e)
|
console.warn(e)
|
||||||
@@ -140,10 +143,12 @@ const OpenFeedback = ({
|
|||||||
const RateQuality = ({
|
const RateQuality = ({
|
||||||
posthog,
|
posthog,
|
||||||
onNext,
|
onNext,
|
||||||
|
metadata,
|
||||||
maxRating = 7,
|
maxRating = 7,
|
||||||
}: {
|
}: {
|
||||||
posthog: PostHog
|
posthog: PostHog
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
maxRating?: number
|
maxRating?: number
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'rating' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'rating' })
|
||||||
@@ -155,9 +160,9 @@ const RateQuality = ({
|
|||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit = () => {
|
||||||
try {
|
try {
|
||||||
posthog.capture('survey sent', {
|
posthog.capture('quality-rating', {
|
||||||
$survey_id: '01933c22-d005-0000-b623-20b752171e2e',
|
rating: selectedRating,
|
||||||
$survey_response: `${selectedRating}`,
|
...metadata,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e)
|
console.warn(e)
|
||||||
@@ -299,7 +304,19 @@ const AuthenticationMessage = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Rating = () => {
|
type RatingMetadata = {
|
||||||
|
room_id?: string
|
||||||
|
pc_publisher?: CandidateInfo
|
||||||
|
pc_subscriber?: CandidateInfo
|
||||||
|
pc_publisher_changes_count?: number
|
||||||
|
pc_subscriber_changes_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Rating = ({
|
||||||
|
metadata: metadataProp,
|
||||||
|
}: {
|
||||||
|
metadata: RatingMetadata
|
||||||
|
}) => {
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
const posthog = usePostHog()
|
const posthog = usePostHog()
|
||||||
|
|
||||||
@@ -309,14 +326,36 @@ export const Rating = () => {
|
|||||||
|
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState(0)
|
||||||
|
|
||||||
|
const sessionId = useMemo(() => crypto.randomUUID(), [])
|
||||||
|
|
||||||
|
const metadata = useMemo(
|
||||||
|
() => ({
|
||||||
|
session_id: sessionId,
|
||||||
|
...metadataProp,
|
||||||
|
}),
|
||||||
|
[sessionId, metadataProp]
|
||||||
|
)
|
||||||
|
|
||||||
if (!isAnalyticsEnabled) return
|
if (!isAnalyticsEnabled) return
|
||||||
|
|
||||||
if (step == 0) {
|
if (step == 0) {
|
||||||
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
|
return (
|
||||||
|
<RateQuality
|
||||||
|
posthog={posthog}
|
||||||
|
onNext={() => setStep(step + 1)}
|
||||||
|
metadata={metadata}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step == 1) {
|
if (step == 1) {
|
||||||
return <OpenFeedback posthog={posthog} onNext={() => setStep(step + 1)} />
|
return (
|
||||||
|
<OpenFeedback
|
||||||
|
posthog={posthog}
|
||||||
|
onNext={() => setStep(step + 1)}
|
||||||
|
metadata={metadata}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step == 2) {
|
if (step == 2) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const AdminToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isAdminOpen}
|
isSelected={isAdminOpen}
|
||||||
|
aria-expanded={isAdminOpen}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleAdmin()
|
toggleAdmin()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { FullScreenShareWarning } from './FullScreenShareWarning'
|
|||||||
import { ParticipantName } from './ParticipantName'
|
import { ParticipantName } from './ParticipantName'
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||||
|
|
||||||
export function TrackRefContextIfNeeded(
|
export function TrackRefContextIfNeeded(
|
||||||
@@ -237,7 +239,13 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
</TrackRefContextIfNeeded>
|
</TrackRefContextIfNeeded>
|
||||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
<KeyboardShortcutHint>
|
||||||
|
{t('toolbarHint', {
|
||||||
|
shortcut: formatShortcutLabel(
|
||||||
|
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</KeyboardShortcutHint>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -25,6 +26,7 @@ type StyledSidePanelProps = {
|
|||||||
isSubmenu: boolean
|
isSubmenu: boolean
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
backButtonLabel: string
|
backButtonLabel: string
|
||||||
|
isReactionToolbarOpen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledSidePanel = ({
|
const StyledSidePanel = ({
|
||||||
@@ -33,6 +35,7 @@ const StyledSidePanel = ({
|
|||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
isClosed,
|
isClosed,
|
||||||
|
isReactionToolbarOpen,
|
||||||
closeButtonTooltip,
|
closeButtonTooltip,
|
||||||
isSubmenu = false,
|
isSubmenu = false,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -51,17 +54,23 @@ const StyledSidePanel = ({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
margin: '1.5rem 1.5rem 1.5rem 0',
|
margin: 'var(--sizes-room-side-panel-margin)',
|
||||||
|
marginLeft: 0,
|
||||||
|
marginBottom: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: '80px',
|
width: 'var(--sizes-room-side-panel)',
|
||||||
width: '360px',
|
|
||||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
transform: isClosed
|
||||||
|
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
||||||
|
: 'none',
|
||||||
|
bottom: isReactionToolbarOpen
|
||||||
|
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
||||||
|
: 'var(--sizes-room-control-bar)',
|
||||||
}}
|
}}
|
||||||
aria-hidden={isClosed}
|
aria-hidden={isClosed}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
@@ -149,11 +158,14 @@ export const SidePanel = () => {
|
|||||||
activeSubPanelId,
|
activeSubPanelId,
|
||||||
} = useSidePanel()
|
} = useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||||
|
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||||
|
|
||||||
|
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
title={title}
|
||||||
ariaLabel={t('ariaLabel')}
|
ariaLabel={t('ariaLabel', { title })}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
layoutStore.activePanelId = null
|
layoutStore.activePanelId = null
|
||||||
layoutStore.activeSubPanelId = null
|
layoutStore.activeSubPanelId = null
|
||||||
@@ -163,6 +175,7 @@ export const SidePanel = () => {
|
|||||||
})}
|
})}
|
||||||
isClosed={!isSidePanelOpen}
|
isClosed={!isSidePanelOpen}
|
||||||
isSubmenu={isSubPanelOpen}
|
isSubmenu={isSubPanelOpen}
|
||||||
|
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||||
backButtonLabel={t('backToTools')}
|
backButtonLabel={t('backToTools')}
|
||||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const ToolButton = ({
|
|||||||
<div>
|
<div>
|
||||||
<Text
|
<Text
|
||||||
margin={false}
|
margin={false}
|
||||||
as="h3"
|
as="h2"
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 0.25,
|
gap: 0.25,
|
||||||
|
|||||||
+16
-27
@@ -11,11 +11,7 @@ import {
|
|||||||
TIMEOUT_TICK,
|
TIMEOUT_TICK,
|
||||||
timerWorkerScript,
|
timerWorkerScript,
|
||||||
} from './TimerWorker'
|
} from './TimerWorker'
|
||||||
import {
|
import { BackgroundProcessorInterface, ProcessorConfig, ProcessorType } from '.'
|
||||||
BackgroundProcessorInterface,
|
|
||||||
BackgroundOptions,
|
|
||||||
ProcessorType,
|
|
||||||
} from '.'
|
|
||||||
|
|
||||||
const PROCESSING_WIDTH = 256
|
const PROCESSING_WIDTH = 256
|
||||||
const PROCESSING_HEIGHT = 144
|
const PROCESSING_HEIGHT = 144
|
||||||
@@ -33,7 +29,7 @@ const DEFAULT_BLUR = '10'
|
|||||||
* MediaStreamTrackProcessor.
|
* MediaStreamTrackProcessor.
|
||||||
*/
|
*/
|
||||||
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||||
options: BackgroundOptions
|
options: ProcessorConfig
|
||||||
name: string
|
name: string
|
||||||
processedTrack?: MediaStreamTrack | undefined
|
processedTrack?: MediaStreamTrack | undefined
|
||||||
|
|
||||||
@@ -64,15 +60,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
type: ProcessorType
|
type: ProcessorType
|
||||||
virtualBackgroundImage?: HTMLImageElement
|
virtualBackgroundImage?: HTMLImageElement
|
||||||
|
|
||||||
constructor(opts: BackgroundOptions) {
|
constructor(opts: ProcessorConfig) {
|
||||||
this.name = 'blur'
|
this.name = 'blur'
|
||||||
this.options = opts
|
this.options = opts
|
||||||
|
this.type = opts.type
|
||||||
if (this.options.blurRadius) {
|
|
||||||
this.type = ProcessorType.BLUR
|
|
||||||
} else {
|
|
||||||
this.type = ProcessorType.VIRTUAL
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static get isSupported() {
|
static get isSupported() {
|
||||||
@@ -107,6 +98,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_initVirtualBackgroundImage() {
|
_initVirtualBackgroundImage() {
|
||||||
|
if (this.options.type !== 'virtual') {
|
||||||
|
throw new Error(
|
||||||
|
'Virtual background is only supported for virtual background'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const needsUpdate =
|
const needsUpdate =
|
||||||
this.options.imagePath &&
|
this.options.imagePath &&
|
||||||
this.virtualBackgroundImage &&
|
this.virtualBackgroundImage &&
|
||||||
@@ -118,7 +115,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: BackgroundOptions): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
this.options = opts
|
this.options = opts
|
||||||
this._initVirtualBackgroundImage()
|
this._initVirtualBackgroundImage()
|
||||||
}
|
}
|
||||||
@@ -187,7 +184,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
PROCESSING_WIDTH,
|
PROCESSING_WIDTH,
|
||||||
PROCESSING_WIDTH
|
PROCESSING_HEIGHT
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +209,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
* TODO: future improvement with WebGL.
|
* TODO: future improvement with WebGL.
|
||||||
*/
|
*/
|
||||||
async blur() {
|
async blur() {
|
||||||
|
if (this.options.type !== 'blur') {
|
||||||
|
throw new Error('Blurring is only supported for blur background')
|
||||||
|
}
|
||||||
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
|
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
|
||||||
for (let i = 0; i < mask.length; ++i) {
|
for (let i = 0; i < mask.length; ++i) {
|
||||||
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
|
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
|
||||||
@@ -293,7 +293,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
await this.sizeSource()
|
await this.sizeSource()
|
||||||
await this.segment()
|
await this.segment()
|
||||||
|
|
||||||
if (this.options.blurRadius) {
|
if (this.options.type === 'blur') {
|
||||||
await this.blur()
|
await this.blur()
|
||||||
} else {
|
} else {
|
||||||
await this.drawVirtualBackground()
|
await this.drawVirtualBackground()
|
||||||
@@ -354,15 +354,4 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
this.timerWorker?.terminate()
|
this.timerWorker?.terminate()
|
||||||
this.imageSegmenter?.close()
|
this.imageSegmenter?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
clone() {
|
|
||||||
return new BackgroundCustomProcessor(this.options)
|
|
||||||
}
|
|
||||||
|
|
||||||
serialize() {
|
|
||||||
return {
|
|
||||||
type: this.type,
|
|
||||||
options: this.options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,15 +332,4 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
|||||||
this.timerWorker?.terminate()
|
this.timerWorker?.terminate()
|
||||||
this.faceLandmarker?.close()
|
this.faceLandmarker?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
clone() {
|
|
||||||
return new FaceLandmarksProcessor(this.options)
|
|
||||||
}
|
|
||||||
|
|
||||||
serialize() {
|
|
||||||
return {
|
|
||||||
type: this.type,
|
|
||||||
options: this.options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-32
@@ -1,28 +1,23 @@
|
|||||||
import { ProcessorOptions, Track } from 'livekit-client'
|
import { ProcessorOptions, Track } from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
BackgroundBlur,
|
BackgroundBlur,
|
||||||
BackgroundTransformer,
|
|
||||||
ProcessorWrapper,
|
ProcessorWrapper,
|
||||||
VirtualBackground,
|
VirtualBackground,
|
||||||
} from '@livekit/track-processors'
|
} from '@livekit/track-processors'
|
||||||
import {
|
import { ProcessorConfig, BackgroundProcessorInterface, ProcessorType } from '.'
|
||||||
BackgroundOptions,
|
|
||||||
BackgroundProcessorInterface,
|
|
||||||
ProcessorType,
|
|
||||||
} from '.'
|
|
||||||
|
|
||||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||||
processor: ProcessorWrapper<BackgroundOptions>
|
processor: ProcessorWrapper<{ imagePath?: string; blurRadius?: number }>
|
||||||
opts: BackgroundOptions
|
opts: ProcessorConfig
|
||||||
processorType: ProcessorType
|
processorType: ProcessorType
|
||||||
|
|
||||||
constructor(opts: BackgroundOptions) {
|
constructor(opts: ProcessorConfig) {
|
||||||
this.opts = opts
|
this.opts = opts
|
||||||
|
|
||||||
if (opts.imagePath) {
|
if (opts.type === 'virtual') {
|
||||||
this.processorType = ProcessorType.VIRTUAL
|
this.processorType = ProcessorType.VIRTUAL
|
||||||
this.processor = VirtualBackground(opts.imagePath)
|
this.processor = VirtualBackground(opts.imagePath)
|
||||||
} else if (opts.blurRadius !== undefined) {
|
} else if (opts.type === 'blur') {
|
||||||
this.processorType = ProcessorType.BLUR
|
this.processorType = ProcessorType.BLUR
|
||||||
this.processor = BackgroundBlur(opts.blurRadius)
|
this.processor = BackgroundBlur(opts.blurRadius)
|
||||||
} else {
|
} else {
|
||||||
@@ -44,24 +39,23 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
return this.processor.destroy()
|
return this.processor.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(opts: BackgroundOptions): Promise<void> {
|
async update(opts: ProcessorConfig): Promise<void> {
|
||||||
const newProcessorType = opts.imagePath
|
this.opts = opts
|
||||||
? ProcessorType.VIRTUAL
|
|
||||||
: ProcessorType.BLUR
|
const newProcessorType =
|
||||||
|
opts.type === 'virtual' ? ProcessorType.VIRTUAL : ProcessorType.BLUR
|
||||||
|
|
||||||
let processedOpts = opts
|
|
||||||
if (newProcessorType !== this.processorType) {
|
if (newProcessorType !== this.processorType) {
|
||||||
this.processorType = newProcessorType
|
this.processorType = newProcessorType
|
||||||
if (newProcessorType === ProcessorType.VIRTUAL) {
|
if (newProcessorType === ProcessorType.VIRTUAL) {
|
||||||
this.processor.name = 'virtual-background'
|
this.processor.name = 'virtual-background'
|
||||||
processedOpts = { ...opts, blurRadius: undefined }
|
|
||||||
} else {
|
} else {
|
||||||
this.processor.name = 'background-blur'
|
this.processor.name = 'background-blur'
|
||||||
processedOpts = { ...opts, imagePath: undefined }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.processor.updateTransformerOptions(processedOpts)
|
await this.processor.updateTransformerOptions(
|
||||||
this.opts = processedOpts
|
opts as { imagePath?: string; blurRadius?: number }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
@@ -73,17 +67,6 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
|||||||
}
|
}
|
||||||
|
|
||||||
get options() {
|
get options() {
|
||||||
return (this.processor.transformer as BackgroundTransformer).options
|
return this.opts
|
||||||
}
|
|
||||||
|
|
||||||
clone() {
|
|
||||||
return new UnifiedBackgroundTrackProcessor(this.options || this.opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
serialize() {
|
|
||||||
return {
|
|
||||||
type: this.processorType,
|
|
||||||
options: this.options,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,7 @@ import { ProcessorWrapper } from '@livekit/track-processors'
|
|||||||
import { Track, TrackProcessor } from 'livekit-client'
|
import { Track, TrackProcessor } from 'livekit-client'
|
||||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||||
|
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
||||||
export type BackgroundOptions = {
|
|
||||||
blurRadius?: number
|
|
||||||
imagePath?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessorSerialized {
|
|
||||||
type: ProcessorType
|
|
||||||
options: BackgroundOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
|
||||||
update(opts: BackgroundOptions): Promise<void>
|
|
||||||
options: BackgroundOptions
|
|
||||||
clone(): BackgroundProcessorInterface
|
|
||||||
serialize(): ProcessorSerialized
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ProcessorType {
|
export enum ProcessorType {
|
||||||
BLUR = 'blur',
|
BLUR = 'blur',
|
||||||
@@ -26,6 +10,16 @@ export enum ProcessorType {
|
|||||||
FACE_LANDMARKS = 'faceLandmarks',
|
FACE_LANDMARKS = 'faceLandmarks',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProcessorConfig =
|
||||||
|
| { type: ProcessorType.BLUR; blurRadius: number }
|
||||||
|
| { type: ProcessorType.VIRTUAL; imagePath: string; fileId?: string }
|
||||||
|
| ({ type: ProcessorType.FACE_LANDMARKS } & FaceLandmarksOptions)
|
||||||
|
|
||||||
|
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
||||||
|
update(opts: ProcessorConfig): Promise<void>
|
||||||
|
options: ProcessorConfig
|
||||||
|
}
|
||||||
|
|
||||||
export class BackgroundProcessorFactory {
|
export class BackgroundProcessorFactory {
|
||||||
static hasModernApiSupport() {
|
static hasModernApiSupport() {
|
||||||
return ProcessorWrapper.hasModernApiSupport
|
return ProcessorWrapper.hasModernApiSupport
|
||||||
@@ -36,28 +30,27 @@ export class BackgroundProcessorFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getProcessor(
|
static getProcessor(
|
||||||
type: ProcessorType,
|
config: ProcessorConfig
|
||||||
opts: BackgroundOptions
|
|
||||||
): BackgroundProcessorInterface | undefined {
|
): BackgroundProcessorInterface | undefined {
|
||||||
const isBlur = type === ProcessorType.BLUR
|
const isBlur = config.type === ProcessorType.BLUR
|
||||||
const isVirtual = type === ProcessorType.VIRTUAL
|
const isVirtual = config.type === ProcessorType.VIRTUAL
|
||||||
|
|
||||||
if (!isBlur && !isVirtual) return undefined
|
if (!isBlur && !isVirtual) return undefined
|
||||||
|
|
||||||
if (ProcessorWrapper.isSupported) {
|
if (ProcessorWrapper.isSupported) {
|
||||||
return new UnifiedBackgroundTrackProcessor(opts)
|
return new UnifiedBackgroundTrackProcessor(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (BackgroundCustomProcessor.isSupported) {
|
if (BackgroundCustomProcessor.isSupported) {
|
||||||
return new BackgroundCustomProcessor(opts)
|
return new BackgroundCustomProcessor(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
static deserializeProcessor(data?: ProcessorSerialized) {
|
static fromProcessorConfig(data?: ProcessorConfig) {
|
||||||
if (data?.type) {
|
if (data) {
|
||||||
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
|
return BackgroundProcessorFactory.getProcessor(data)
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const ChatToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isChatOpen}
|
isSelected={isChatOpen}
|
||||||
|
aria-expanded={isChatOpen}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleChat()
|
toggleChat()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ const SelectDevicePermissions = <T extends string | number>({
|
|||||||
iconComponent={iconComponent}
|
iconComponent={iconComponent}
|
||||||
placeholder={items.length === 0 ? t('loading') : t('select')}
|
placeholder={items.length === 0 ? t('loading') : t('select')}
|
||||||
selectedKey={selectedKey}
|
selectedKey={selectedKey}
|
||||||
onSelectionChange={(key) => {
|
onSelectionChange={async (key) => {
|
||||||
if (key === selectedKey) return
|
if (key === selectedKey) return
|
||||||
|
await setActiveMediaDevice(key as string)
|
||||||
onSubmit?.(key as string)
|
onSubmit?.(key as string)
|
||||||
setActiveMediaDevice(key as string)
|
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+2
-2
@@ -82,8 +82,8 @@ export const VideoDeviceControl = ({
|
|||||||
*
|
*
|
||||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||||
*/
|
*/
|
||||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
const processor = BackgroundProcessorFactory.fromProcessorConfig(
|
||||||
userChoices.processorSerialized
|
userChoices.processorConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = trackProps.toggle as (
|
const toggle = trackProps.toggle as (
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const InfoToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isInfoOpen}
|
isSelected={isInfoOpen}
|
||||||
|
aria-expanded={isInfoOpen}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleInfo()
|
toggleInfo()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
+2
@@ -48,6 +48,7 @@ export const ParticipantsToggle = ({
|
|||||||
count: announcedCount,
|
count: announcedCount,
|
||||||
})}.`}
|
})}.`}
|
||||||
isSelected={isParticipantsOpen}
|
isSelected={isParticipantsOpen}
|
||||||
|
aria-expanded={isParticipantsOpen}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleParticipants()
|
toggleParticipants()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
@@ -76,6 +77,7 @@ export const ParticipantsToggle = ({
|
|||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
})}
|
})}
|
||||||
|
aria-hidden={true}
|
||||||
>
|
>
|
||||||
{numParticipants < 100 ? (
|
{numParticipants < 100 ? (
|
||||||
numParticipants || 1
|
numParticipants || 1
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { RiEmotionLine } from '@remixicon/react'
|
|
||||||
import { useState, useRef } from 'react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
|
||||||
import { ToggleButton, Button } from '@/primitives'
|
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
|
||||||
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
|
||||||
import {
|
|
||||||
ANIMATION_DURATION,
|
|
||||||
ReactionPortals,
|
|
||||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
|
||||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
|
||||||
import {
|
|
||||||
Popover as RACPopover,
|
|
||||||
Dialog,
|
|
||||||
DialogTrigger,
|
|
||||||
} from 'react-aria-components'
|
|
||||||
import { FocusScope } from '@react-aria/focus'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export enum Emoji {
|
|
||||||
THUMBS_UP = 'thumbs-up',
|
|
||||||
THUMBS_DOWN = 'thumbs-down',
|
|
||||||
CLAP = 'clapping-hands',
|
|
||||||
HEART = 'red-heart',
|
|
||||||
LAUGHING = 'face-with-tears-of-joy',
|
|
||||||
SURPRISED = 'face-with-open-mouth',
|
|
||||||
CELEBRATION = 'party-popper',
|
|
||||||
PLEASE = 'folded-hands',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Reaction {
|
|
||||||
id: number
|
|
||||||
emoji: string
|
|
||||||
participant: Participant
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReactionsToggle = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
const [reactions, setReactions] = useState<Reaction[]>([])
|
|
||||||
const instanceIdRef = useRef(0)
|
|
||||||
const room = useRoomContext()
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
|
||||||
id: 'reaction',
|
|
||||||
handler: () => setIsOpen((prev) => !prev),
|
|
||||||
})
|
|
||||||
|
|
||||||
const sendReaction = async (emoji: string) => {
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const payload: NotificationPayload = {
|
|
||||||
type: NotificationType.ReactionReceived,
|
|
||||||
data: {
|
|
||||||
emoji: emoji,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const data = encoder.encode(JSON.stringify(payload))
|
|
||||||
await room.localParticipant.publishData(data, { reliable: true })
|
|
||||||
|
|
||||||
const newReaction = {
|
|
||||||
id: instanceIdRef.current++,
|
|
||||||
emoji,
|
|
||||||
participant: room.localParticipant,
|
|
||||||
}
|
|
||||||
setReactions((prev) => [...prev, newReaction])
|
|
||||||
|
|
||||||
// Remove this reaction after animation
|
|
||||||
setTimeout(() => {
|
|
||||||
setReactions((prev) =>
|
|
||||||
prev.filter((instance) => instance.id !== newReaction.id)
|
|
||||||
)
|
|
||||||
}, ANIMATION_DURATION)
|
|
||||||
}
|
|
||||||
|
|
||||||
const debouncedSendReaction = useRateLimiter({
|
|
||||||
callback: sendReaction,
|
|
||||||
maxCalls: 10,
|
|
||||||
windowMs: 1000,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={css({ position: 'relative' })}>
|
|
||||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<ToggleButton
|
|
||||||
square
|
|
||||||
variant="primaryDark"
|
|
||||||
aria-label={t('button')}
|
|
||||||
tooltip={t('button')}
|
|
||||||
isSelected={isOpen}
|
|
||||||
onChange={setIsOpen}
|
|
||||||
>
|
|
||||||
<RiEmotionLine />
|
|
||||||
</ToggleButton>
|
|
||||||
<RACPopover
|
|
||||||
placement="top"
|
|
||||||
offset={8}
|
|
||||||
isNonModal
|
|
||||||
shouldCloseOnInteractOutside={() => false}
|
|
||||||
className={css({
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '0.35rem',
|
|
||||||
backgroundColor: 'primaryDark.50',
|
|
||||||
'&[data-entering]': {
|
|
||||||
animation: 'fade 200ms ease',
|
|
||||||
},
|
|
||||||
'&[data-exiting]': {
|
|
||||||
animation: 'fade 200ms ease-in reverse',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Dialog className={css({ outline: 'none' })}>
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
|
|
||||||
<FocusScope contain autoFocus restoreFocus>
|
|
||||||
<div
|
|
||||||
role="toolbar"
|
|
||||||
aria-orientation="horizontal"
|
|
||||||
aria-label={t('button')}
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{Object.values(Emoji).map((emoji, index) => (
|
|
||||||
<Button
|
|
||||||
key={index}
|
|
||||||
onPress={() => debouncedSendReaction(emoji)}
|
|
||||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
|
||||||
variant="primaryTextDark"
|
|
||||||
size="sm"
|
|
||||||
square
|
|
||||||
data-attr={`send-reaction-${emoji}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`/assets/reactions/${emoji}.png`}
|
|
||||||
alt=""
|
|
||||||
className={css({
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
userSelect: 'none',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</FocusScope>
|
|
||||||
</Dialog>
|
|
||||||
</RACPopover>
|
|
||||||
</DialogTrigger>
|
|
||||||
</div>
|
|
||||||
<ReactionPortals reactions={reactions} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -34,6 +34,7 @@ export const ToolsToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isToolsOpen}
|
isSelected={isToolsOpen}
|
||||||
|
aria-expanded={isToolsOpen}
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleTools()
|
toggleTools()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ import { useLocalParticipant } from '@livekit/components-react'
|
|||||||
import { LocalVideoTrack } from 'livekit-client'
|
import { LocalVideoTrack } from 'livekit-client'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { EffectsConfiguration } from './EffectsConfiguration'
|
import { EffectsConfiguration } from './EffectsConfiguration'
|
||||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
|
||||||
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
|
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
|
||||||
import { TrackSource } from '@livekit/protocol'
|
import { TrackSource } from '@livekit/protocol'
|
||||||
|
|
||||||
export const Effects = () => {
|
export const Effects = () => {
|
||||||
const { cameraTrack } = useLocalParticipant()
|
const { cameraTrack } = useLocalParticipant()
|
||||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
||||||
const { saveProcessorSerialized } = usePersistentUserChoices()
|
|
||||||
|
|
||||||
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
||||||
|
|
||||||
@@ -24,9 +22,6 @@ export const Effects = () => {
|
|||||||
isDisabled={!canPublishCamera}
|
isDisabled={!canPublishCamera}
|
||||||
videoTrack={localCameraTrack}
|
videoTrack={localCameraTrack}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onSubmit={(processor) =>
|
|
||||||
saveProcessorSerialized(processor?.serialize())
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+705
-251
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ export const FunnyEffects = ({
|
|||||||
showFrench: false,
|
showFrench: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return processor.serialize().options
|
return { ...processor.options }
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = getOptions()
|
const options = getOptions()
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { connectionObserverStore } from '@/stores/connectionObserver'
|
|||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useIsAdvancedConnectionObserverEnabled } from './useIsAdvancedConnectionObserverEnabled'
|
||||||
|
|
||||||
|
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
||||||
|
|
||||||
export const useConnectionObserver = () => {
|
export const useConnectionObserver = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
@@ -17,6 +20,8 @@ export const useConnectionObserver = () => {
|
|||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
|
const isAdvancedConnectionObserverEnabled =
|
||||||
|
useIsAdvancedConnectionObserverEnabled()
|
||||||
|
|
||||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||||
|
|
||||||
@@ -67,6 +72,100 @@ export const useConnectionObserver = () => {
|
|||||||
userPreferencesSnap.is_idle_disconnect_modal_enabled,
|
userPreferencesSnap.is_idle_disconnect_modal_enabled,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdvancedConnectionObserverEnabled) return
|
||||||
|
if (!room) return
|
||||||
|
|
||||||
|
let interval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const pollCandidate = async (
|
||||||
|
label: 'publisher' | 'subscriber',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
pc?: any
|
||||||
|
) => {
|
||||||
|
if (!pc) return
|
||||||
|
|
||||||
|
let stats: RTCStatsReport
|
||||||
|
try {
|
||||||
|
stats = await pc.getStats()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
stats.forEach((report: any) => {
|
||||||
|
if (
|
||||||
|
report.type === 'candidate-pair' &&
|
||||||
|
report.state === 'succeeded' &&
|
||||||
|
report.nominated
|
||||||
|
) {
|
||||||
|
const remoteCandidate = stats.get(report.remoteCandidateId)
|
||||||
|
if (!remoteCandidate) return
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
type: remoteCandidate.candidateType,
|
||||||
|
address: remoteCandidate.address,
|
||||||
|
protocol: remoteCandidate.protocol,
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = connectionObserverStore[label]
|
||||||
|
|
||||||
|
const hasChanged =
|
||||||
|
current?.type !== next.type ||
|
||||||
|
current?.address !== next.address ||
|
||||||
|
current?.protocol !== next.protocol
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
|
connectionObserverStore[label] = next
|
||||||
|
const key = `${label}ChangesCount` as const
|
||||||
|
connectionObserverStore[key] =
|
||||||
|
(connectionObserverStore[key] || 0) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
const publisher = room.engine?.pcManager?.publisher
|
||||||
|
const subscriber = room.engine?.pcManager?.subscriber
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
pollCandidate('publisher', publisher),
|
||||||
|
pollCandidate('subscriber', subscriber),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPolling = async () => {
|
||||||
|
if (interval) return // prevent duplicates
|
||||||
|
|
||||||
|
// Initial snapshot
|
||||||
|
await poll()
|
||||||
|
|
||||||
|
interval = setInterval(poll, CANDIDATE_POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (!interval) return
|
||||||
|
clearInterval(interval)
|
||||||
|
interval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
room.on(RoomEvent.Connected, startPolling)
|
||||||
|
room.on(RoomEvent.Reconnected, startPolling)
|
||||||
|
|
||||||
|
room.on(RoomEvent.Reconnecting, stopPolling)
|
||||||
|
room.on(RoomEvent.Disconnected, stopPolling)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopPolling()
|
||||||
|
|
||||||
|
room.off(RoomEvent.Connected, startPolling)
|
||||||
|
room.off(RoomEvent.Reconnected, startPolling)
|
||||||
|
room.off(RoomEvent.Reconnecting, stopPolling)
|
||||||
|
room.off(RoomEvent.Disconnected, stopPolling)
|
||||||
|
}
|
||||||
|
}, [room, isAdvancedConnectionObserverEnabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAnalyticsEnabled) return
|
if (!isAnalyticsEnabled) return
|
||||||
|
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||||
|
import { FeatureFlags } from '@/features/analytics/enums.ts'
|
||||||
|
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled.ts'
|
||||||
|
import { isMobileBrowser } from '@livekit/components-core'
|
||||||
|
|
||||||
|
export const useIsAdvancedConnectionObserverEnabled = () => {
|
||||||
|
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
||||||
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
|
|
||||||
|
const isMobile = isMobileBrowser()
|
||||||
|
return !isMobile && isAnalyticsEnabled && featureEnabled
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
import { userChoicesStore } from '@/stores/userChoices'
|
||||||
import type { VideoResolution } from '@/stores/userChoices'
|
import type { VideoResolution } from '@/stores/userChoices'
|
||||||
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
|
import { ProcessorConfig } from '@/features/rooms/livekit/components/blur'
|
||||||
import type { VideoQuality } from 'livekit-client'
|
import type { VideoQuality } from 'livekit-client'
|
||||||
|
|
||||||
export function usePersistentUserChoices() {
|
export function usePersistentUserChoices() {
|
||||||
@@ -36,10 +36,8 @@ export function usePersistentUserChoices() {
|
|||||||
saveNoiseReductionEnabled: (enabled: boolean) => {
|
saveNoiseReductionEnabled: (enabled: boolean) => {
|
||||||
userChoicesStore.noiseReductionEnabled = enabled
|
userChoicesStore.noiseReductionEnabled = enabled
|
||||||
},
|
},
|
||||||
saveProcessorSerialized: (
|
saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => {
|
||||||
processorSerialized: ProcessorSerialized | undefined
|
userChoicesStore.processorConfig = processorConfig
|
||||||
) => {
|
|
||||||
userChoicesStore.processorSerialized = processorSerialized
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { LocalParticipant, Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
import {
|
import {
|
||||||
useParticipantAttribute,
|
useParticipantAttribute,
|
||||||
useParticipants,
|
useParticipants,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { isLocal } from '@/utils/livekit'
|
import { isLocal } from '@/utils/livekit'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
import { useRaiseHand } from '@/features/rooms/api/updateRaiseHand'
|
||||||
|
|
||||||
type useRaisedHandProps = {
|
type useRaisedHandProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
@@ -40,18 +41,19 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
|
|||||||
const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
|
const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
|
||||||
participant,
|
participant,
|
||||||
})
|
})
|
||||||
|
const { raiseHand } = useRaiseHand()
|
||||||
|
|
||||||
const isHandRaised = !!handRaisedAtAttribute
|
const isHandRaised = !!handRaisedAtAttribute
|
||||||
|
|
||||||
const toggleRaisedHand = async () => {
|
const toggleRaisedHand = async () => {
|
||||||
if (!isLocal(participant)) return
|
if (!isLocal(participant)) return
|
||||||
const localParticipant = participant as LocalParticipant
|
try {
|
||||||
|
await raiseHand(!isHandRaised)
|
||||||
const attributes: Record<string, string> = {
|
} catch (e) {
|
||||||
handRaisedAt: !isHandRaised ? new Date().toISOString() : '',
|
console.error(
|
||||||
|
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await localParticipant.setAttributes(attributes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isHandRaised, toggleRaisedHand }
|
return { isHandRaised, toggleRaisedHand }
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import * as React from 'react'
|
|||||||
import { MobileControlBar } from './MobileControlBar'
|
import { MobileControlBar } from './MobileControlBar'
|
||||||
import { DesktopControlBar } from './DesktopControlBar'
|
import { DesktopControlBar } from './DesktopControlBar'
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
|
import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
||||||
@@ -16,10 +18,32 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
if (isMobile) {
|
return (
|
||||||
return <MobileControlBar onDeviceError={onDeviceError} />
|
<div
|
||||||
}
|
className={css({
|
||||||
return <DesktopControlBar onDeviceError={onDeviceError} />
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ReactionsToolbar />
|
||||||
|
<div
|
||||||
|
id="control-bar"
|
||||||
|
className={css({
|
||||||
|
zIndex: 100,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileControlBar onDeviceError={onDeviceError} />
|
||||||
|
) : (
|
||||||
|
<DesktopControlBar onDeviceError={onDeviceError} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
|
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ControlBarAuxProps } from './ControlBar'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
|
|
||||||
import { HandToggle } from '../../components/controls/HandToggle'
|
import { HandToggle } from '../../components/controls/HandToggle'
|
||||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||||
import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
||||||
@@ -15,6 +14,8 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
|
|||||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||||
|
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
||||||
|
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
||||||
|
|
||||||
export function DesktopControlBar({
|
export function DesktopControlBar({
|
||||||
onDeviceError,
|
onDeviceError,
|
||||||
@@ -48,11 +49,7 @@ export function DesktopControlBar({
|
|||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
position: 'absolute',
|
|
||||||
padding: '1.125rem',
|
padding: '1.125rem',
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -65,15 +62,7 @@ export function DesktopControlBar({
|
|||||||
marginLeft: '0.5rem',
|
marginLeft: '0.5rem',
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<div
|
<ControlBarRegion>
|
||||||
className={css({
|
|
||||||
flex: '1 1 33%',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.65rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<AudioDevicesControl
|
<AudioDevicesControl
|
||||||
onDeviceError={(error) =>
|
onDeviceError={(error) =>
|
||||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||||
@@ -97,7 +86,7 @@ export function DesktopControlBar({
|
|||||||
<OptionsButton />
|
<OptionsButton />
|
||||||
<LeaveButton />
|
<LeaveButton />
|
||||||
<StartMediaButton />
|
<StartMediaButton />
|
||||||
</div>
|
</ControlBarRegion>
|
||||||
<MoreOptions parentElement={desktopControlBarEl} />
|
<MoreOptions parentElement={desktopControlBarEl} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useConfig } from '@/api/useConfig'
|
|||||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
|
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
||||||
|
|
||||||
export function MobileControlBar({
|
export function MobileControlBar({
|
||||||
onDeviceError,
|
onDeviceError,
|
||||||
@@ -43,21 +44,11 @@ export function MobileControlBar({
|
|||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
position: 'absolute',
|
|
||||||
padding: '1.125rem',
|
padding: '1.125rem',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<ControlBarRegion mobile>
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
width: '330px',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<LeaveButton />
|
<LeaveButton />
|
||||||
<AudioDevicesControl
|
<AudioDevicesControl
|
||||||
onDeviceError={(error) =>
|
onDeviceError={(error) =>
|
||||||
@@ -82,7 +73,7 @@ export function MobileControlBar({
|
|||||||
>
|
>
|
||||||
<RiMore2Line />
|
<RiMore2Line />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ControlBarRegion>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveMenu
|
<ResponsiveMenu
|
||||||
isOpened={isMenuOpened}
|
isOpened={isMenuOpened}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const MoreOptions = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { width: parentWidth } = useSize(parentElement)
|
const { width: parentWidth } = useSize(parentElement)
|
||||||
return (
|
return (
|
||||||
<div
|
<nav
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
@@ -88,6 +88,6 @@ export const MoreOptions = ({
|
|||||||
) : (
|
) : (
|
||||||
<LateralMenu />
|
<LateralMenu />
|
||||||
)}
|
)}
|
||||||
</div>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,9 @@ import {
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { ControlBar } from './ControlBar/ControlBar'
|
import { ControlBar } from './ControlBar/ControlBar'
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { cva } from '@/styled-system/css'
|
|
||||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
|
||||||
import { FocusLayout } from '../components/FocusLayout'
|
import { FocusLayout } from '../components/FocusLayout'
|
||||||
import { ParticipantTile } from '../components/ParticipantTile'
|
import { ParticipantTile } from '../components/ParticipantTile'
|
||||||
import { SidePanel } from '../components/SidePanel'
|
import { SidePanel } from '../components/SidePanel'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
|
||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||||
@@ -37,35 +33,13 @@ import { useSettingsDialog } from '@/features/settings'
|
|||||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||||
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
|
||||||
import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
|
||||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
|
||||||
import { GridLayout } from '../components/layout/GridLayout'
|
|
||||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||||
const LayoutWrapper = styled(
|
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
||||||
'div',
|
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||||
cva({
|
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
||||||
base: {
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
width: '100%',
|
|
||||||
transition: 'height .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
areSubtitlesOpen: {
|
|
||||||
true: {
|
|
||||||
height: 'calc(100% - 12rem)',
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
@@ -253,9 +227,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
])
|
])
|
||||||
/* eslint-enable react-hooks/exhaustive-deps */
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
const { isSidePanelOpen } = useSidePanel()
|
|
||||||
const { areSubtitlesOpen } = useSubtitles()
|
|
||||||
|
|
||||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -276,57 +247,35 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
onClose={() => setIsShareErrorVisible(false)}
|
onClose={() => setIsShareErrorVisible(false)}
|
||||||
/>
|
/>
|
||||||
<IsIdleDisconnectModal />
|
<IsIdleDisconnectModal />
|
||||||
<div
|
<RoomContentArea>
|
||||||
// todo - extract these magic values into constant
|
{!focusTrack ? (
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: isSidePanelOpen
|
|
||||||
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
|
|
||||||
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
|
|
||||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
|
||||||
maxHeight: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
className="lk-grid-layout-wrapper"
|
||||||
display: 'flex',
|
style={{ height: 'auto' }}
|
||||||
position: 'relative',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{!focusTrack ? (
|
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||||
<div
|
<ParticipantTile />
|
||||||
className="lk-grid-layout-wrapper"
|
</GridLayout>
|
||||||
style={{ height: 'auto' }}
|
|
||||||
>
|
|
||||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
|
||||||
<ParticipantTile />
|
|
||||||
</GridLayout>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="lk-focus-layout-wrapper"
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
>
|
|
||||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
|
||||||
<CarouselLayout
|
|
||||||
tracks={carouselTracks}
|
|
||||||
style={{
|
|
||||||
minWidth: '200px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ParticipantTile />
|
|
||||||
</CarouselLayout>
|
|
||||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
|
||||||
</FocusLayoutContainer>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</LayoutWrapper>
|
) : (
|
||||||
<Subtitles />
|
<div
|
||||||
<MainNotificationToast />
|
className="lk-focus-layout-wrapper"
|
||||||
</div>
|
style={{ height: 'auto' }}
|
||||||
|
>
|
||||||
|
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||||
|
<CarouselLayout
|
||||||
|
tracks={carouselTracks}
|
||||||
|
style={{
|
||||||
|
minWidth: '200px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParticipantTile />
|
||||||
|
</CarouselLayout>
|
||||||
|
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||||
|
</FocusLayoutContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</RoomContentArea>
|
||||||
<ControlBar
|
<ControlBar
|
||||||
onDeviceError={(e) => {
|
onDeviceError={(e) => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
@@ -346,6 +295,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<ConnectionStateToast />
|
<ConnectionStateToast />
|
||||||
<RecordingProvider />
|
<RecordingProvider />
|
||||||
<SettingsDialogProvider />
|
<SettingsDialogProvider />
|
||||||
|
<ReactionPortals />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user