mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b56bcc38c8 | |||
| c3abd0441f | |||
| 79245389ce | |||
| 3b3f992834 | |||
| a98dc1484a | |||
| dca24a1b25 | |||
| 74b791e207 | |||
| df1495c97b | |||
| c95e1c67bd | |||
| f115c83752 | |||
| ebfcb42a7d | |||
| c86a47f736 | |||
| dd6b4512c8 | |||
| f82fd4bece | |||
| edab18d94a | |||
| 636c2168be | |||
| d54e9c2ad0 | |||
| 657712d7cb | |||
| d2bfbee389 | |||
| 9ba97fd14f | |||
| be0d0927d4 | |||
| 27dce44d40 | |||
| 78acaf395e | |||
| 4e5e648730 | |||
| 924fe95d94 | |||
| e3e33c7d0a | |||
| 16ee575ff8 | |||
| e42b083f20 | |||
| 6d2c31eb0a | |||
| e9bdf173de | |||
| dd23ce817a | |||
| 1ca8f6e5ea | |||
| f98e884067 | |||
| b498926353 | |||
| 3d4dc2d631 | |||
| 1523e6aec9 | |||
| 99510c9c6a | |||
| 9f003e95f3 | |||
| 46c30b6fcd | |||
| 1533ae8a3c | |||
| 526e96797e | |||
| 8903a55008 | |||
| 2a60b49086 | |||
| aee1847303 | |||
| 6b7cd8ab2e | |||
| cc2a0ad322 | |||
| 3300226bbe | |||
| 8efb11b3ae | |||
| 8cf15cabaf | |||
| 9b7c449ca5 | |||
| 5c27aba00f | |||
| 7256b87511 | |||
| 7b7951a0e0 | |||
| 5c15140783 | |||
| 898bc9a0f8 | |||
| fd1715bacf | |||
| c9de7d049f | |||
| 6368b676a6 | |||
| 65789ef706 | |||
| c0feb1ee82 | |||
| 135b99aee7 | |||
| 040df0e15a | |||
| 00e197b216 | |||
| eae1a382d5 | |||
| d4a7cf279c | |||
| 70a296eea6 | |||
| ac85a20271 | |||
| 13036f6ab7 | |||
| 64819b3696 | |||
| 53722ad1bc | |||
| dcfdd35c82 | |||
| 16f465432d | |||
| 90f95ab2a9 | |||
| 61f7ad05e9 | |||
| 69a6dd1463 | |||
| 6d06aee92d | |||
| 54908b9caa | |||
| 08af6e77bb | |||
| 4525c9c255 | |||
| 44d3ed8f2e | |||
| 9522dc72ac | |||
| 09dbb250a7 | |||
| 321cb99f82 | |||
| 52bad6faaf | |||
| 0065527a5e | |||
| 818d20888e | |||
| f1fce4431f | |||
| 491866e584 | |||
| f777f2fdeb | |||
| e5184695bb | |||
| 71d59dd9f1 | |||
| 3537fdf648 | |||
| 7b485377cf | |||
| f5a5fa93af | |||
| aca3261a9a | |||
| 8c3d1bdd95 | |||
| a01f0256a1 | |||
| 27ebc2f2e3 | |||
| 553df5070e | |||
| c79984a883 | |||
| 22b2e6bd1e | |||
| be35c1d6e0 | |||
| 8d653b30e5 | |||
| 5602d256d8 | |||
| d9804172e7 | |||
| 61d0043790 | |||
| 6ccc9ef0bf |
@@ -19,6 +19,8 @@ env:
|
|||||||
DOCKER_USER: 1001:127
|
DOCKER_USER: 1001:127
|
||||||
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||||
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
|
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
|
||||||
|
IS_MULTI_PLATFORM_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
BUILD_PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-backend:
|
build-and-push-backend:
|
||||||
@@ -31,6 +33,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
@@ -43,7 +46,7 @@ jobs:
|
|||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
@@ -60,9 +63,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -76,6 +79,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
@@ -88,7 +92,7 @@ jobs:
|
|||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
@@ -106,9 +110,9 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
target: frontend-production
|
target: frontend-production
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
@@ -134,7 +139,7 @@ jobs:
|
|||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
@@ -152,9 +157,9 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./docker/dinum-frontend/Dockerfile
|
file: ./docker/dinum-frontend/Dockerfile
|
||||||
target: frontend-production
|
target: frontend-production
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -168,6 +173,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
@@ -180,7 +186,7 @@ jobs:
|
|||||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
@@ -200,9 +206,9 @@ jobs:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
file: ./src/summary/Dockerfile
|
file: ./src/summary/Dockerfile
|
||||||
target: production
|
target: production
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -216,6 +222,7 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
@@ -228,7 +235,7 @@ jobs:
|
|||||||
images: lasuite/meet-agents
|
images: lasuite/meet-agents
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
@@ -248,9 +255,9 @@ jobs:
|
|||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
file: ./src/agents/Dockerfile
|
file: ./src/agents/Dockerfile
|
||||||
target: production
|
target: production
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,95 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⬆️(agents) upgrade to python 3.14 slim
|
||||||
|
- ⬆️(dependencies) update python dependencies
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🚀(front) fix frontend build failure
|
||||||
|
- 🐛(makefile) fix args in make test
|
||||||
|
- 🩹(backend) fix case-insensitive email deduplication in merge command
|
||||||
|
- 🐛(summary) support media files with bad streams #1478
|
||||||
|
|
||||||
|
## [1.22.0] - 2026-07-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) cap and paginate tiles in picture-in-picture #1383
|
||||||
|
- 📝(docs) document rebranding the favicon via a volume mount #1443
|
||||||
|
- ✨(backend) add command to clean pending and deleted files
|
||||||
|
- 🧱(helm) run clean files command as cronjob
|
||||||
|
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
||||||
|
- 🩹(frontend) enable screen share button in PiP #1458
|
||||||
|
- 🐛(backend) support unencoded S3 notification object keys #1455
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ✨(summary) generalized stt api call #1420
|
||||||
|
- ♻️(env) refactor env variables handling
|
||||||
|
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
|
||||||
|
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
|
||||||
|
- ⬆️(summary) update docker alpine to 3.24 & ffmpeg to 8.1.2 #1471
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🛂(backend) reject user access tokens on the API
|
||||||
|
- 🩹(helm) fix Helm ingress rendering when passing multiple hosts
|
||||||
|
|
||||||
|
## [1.21.0] - 2026-06-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) allow disabling silent login via a URL parameter
|
||||||
|
- ✨(frontend) allow hiding the login button via a URL parameter
|
||||||
|
- ✨(summary) add optional satisfaction survey footer
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ✨(frontend) enhance noise reduction with BBBA audio processing pipeline
|
||||||
|
- 🚸(frontend) mute join notification sound in larger rooms
|
||||||
|
- 🚸(frontend) mute participants by default when joining a large meeting
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) fix metadata agent collector enabled check
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- ♿️(frontend) improve accessibilty of the Effects panel #1401
|
||||||
|
|
||||||
|
## [1.20.0] - 2026-06-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(addon) improve Outlook add-on: i18n support, feedback link, smarter link
|
||||||
|
- ⬆️(frontend) upgrade react-i18next from 15.1.1 to 17.0.8
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) fix noise reduction left-channel-only audio
|
||||||
|
|
||||||
|
## [1.19.0] - 2026-06-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) add file specific admin #1387
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 🐛(agents) fix bug when closing metadata-collector
|
||||||
|
- ⬆️(dependencies) update python dependencies
|
||||||
|
- ⬆️(frontend) update js dependencies
|
||||||
|
- ♻️(agents) replace deprecated room options API
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🔇(summary) make ffmpeg quiet #1404
|
||||||
|
- 🔒️(backend) prevent accessing files if they are not ready #1395
|
||||||
|
- # ⬆️(backend) upgrade idna to >=3.15 to address CVE-2026-45409
|
||||||
|
|
||||||
## [1.18.0] - 2026-06-03
|
## [1.18.0] - 2026-06-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ create-env-files: \
|
|||||||
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/kube-secret \
|
||||||
env.d/development/multi_user_transcriber
|
env.d/development/multi_user_transcriber \
|
||||||
|
env.d/development/metadata_collector
|
||||||
.PHONY: create-env-files
|
.PHONY: create-env-files
|
||||||
|
|
||||||
bootstrap: ## Prepare Docker images for the project
|
bootstrap: ## Prepare Docker images for the project
|
||||||
@@ -210,24 +211,25 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
|||||||
@$(COMPOSE_RUN_APP) pylint meet demo core
|
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||||
.PHONY: lint-pylint
|
.PHONY: lint-pylint
|
||||||
|
|
||||||
test: ## run project tests
|
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||||
@$(MAKE) test-back-parallel
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
@$(MAKE) test-summary
|
$(MAKE) test-back-parallel ARGS="$${args}" && \
|
||||||
|
$(MAKE) test-summary ARGS="$${args}"
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-back: ## run back-end tests
|
test-back: ## run back-end tests (pass extra pytest args via ARGS)
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
bin/pytest $${args:-${1}}
|
bin/pytest $${args}
|
||||||
.PHONY: test-back
|
.PHONY: test-back
|
||||||
|
|
||||||
test-back-parallel: ## run all back-end tests in parallel
|
test-back-parallel: ## run all back-end tests in parallel (pass extra pytest args via ARGS)
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
bin/pytest -n auto $${args:-${1}}
|
bin/pytest -n auto $${args}
|
||||||
.PHONY: test-back-parallel
|
.PHONY: test-back-parallel
|
||||||
|
|
||||||
test-summary: ## run summary tests
|
test-summary: ## run summary tests (pass extra pytest args via ARGS)
|
||||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||||
bin/pytest-summary $${args:-${1}}
|
bin/pytest-summary $${args}
|
||||||
.PHONY: test-summary
|
.PHONY: test-summary
|
||||||
|
|
||||||
makemigrations: ## run django makemigrations for the Meet project.
|
makemigrations: ## run django makemigrations for the Meet project.
|
||||||
@@ -292,6 +294,9 @@ env.d/development/kube-secret:
|
|||||||
env.d/development/multi_user_transcriber:
|
env.d/development/multi_user_transcriber:
|
||||||
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
||||||
|
|
||||||
|
env.d/development/metadata_collector:
|
||||||
|
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
|
||||||
|
|
||||||
# -- Internationalization
|
# -- Internationalization
|
||||||
|
|
||||||
env.d/development/crowdin:
|
env.d/development/crowdin:
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
||||||
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
|
||||||
|
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -28,6 +31,14 @@
|
|||||||
## La Suite Meet: Simple Video Conferencing
|
## La Suite Meet: Simple Video Conferencing
|
||||||
|
|
||||||
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> New here? Start by introducing yourself in our Matrix channel:
|
||||||
|
> **https://matrix.to/#/#meet-official:matrix.org**
|
||||||
|
>
|
||||||
|
> We’re happy to discuss ideas, answer questions, and help to deploy LaSuite Meet.
|
||||||
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- Optimized for stability in large meetings (+100 p.)
|
- Optimized for stability in large meetings (+100 p.)
|
||||||
- Support for multiple screen sharing streams
|
- Support for multiple screen sharing streams
|
||||||
@@ -52,7 +63,7 @@ We’re continuously adding new features to enhance your experience, with the la
|
|||||||
|
|
||||||
### 🚀 Major roll out to all French public servants
|
### 🚀 Major roll out to all French public servants
|
||||||
|
|
||||||
On the 25th of January 2026, David Amiel, France’s Minister for Civil Service and State Reform, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
|
On the 29th of January 2026, Prime Minister Sébastien Lecornu, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in English](https://www.nytimes.com/2026/01/29/world/europe/france-zoom-alternative-visio.html))
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
@@ -84,22 +95,57 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
|
|||||||
#### Known instances
|
#### Known instances
|
||||||
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
||||||
|
|
||||||
| Url | Org | Access |
|
| Url | Org | Access |
|
||||||
|---------------------------------------------------------------| --- | ------- |
|
|---------------------------------------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||||
|
| [Clever Cloud](https://www.clever.cloud/product/visio/) | clever cloud | Openvisio is a sovereign video conferencing solution based on LaSuite Meet offered by [Clever Cloud](https://www.clever.cloud/). |
|
||||||
|
| [Email.eu](https://email.eu/) | Email.eu | Sovereign business workspace. |
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
# Contributing
|
||||||
|
|
||||||
We <3 contributions of any kind, big and small:
|
We <3 contributions of all kinds **big or small** and we’re genuinely glad you’re here. 🌱
|
||||||
|
|
||||||
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/11/views/4)
|
### Start by saying hi
|
||||||
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
|
||||||
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
|
||||||
|
|
||||||
|
**The best first contribution is simply to come say hi.**
|
||||||
|
|
||||||
|
Before opening a PR, especially a larger one, or one written with the help of AI, we encourage you to reach out to a maintainer on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org) (@antoine.lebaud:matrix.org).
|
||||||
|
|
||||||
|
Getting in touch early helps us align on goals, avoid duplicated or wasted effort, and build a community that stays active, welcoming, and fun to be part of. There are no silly questions here: whether you’ve shipped hundreds of PRs or you’re just getting started, you’re welcome.
|
||||||
|
|
||||||
|
### AI contributions
|
||||||
|
|
||||||
|
AI-assisted contributions are welcome. But code is never the end goal. What matters most is building relationships, sharing knowledge, and growing a sustainable community over time.
|
||||||
|
|
||||||
|
If your contribution has been heavily generated with AI, please be transparent about it. This helps maintainers review it with the right context and respects the time they invest in the project.
|
||||||
|
|
||||||
|
Using AI does not transfer ownership of the contribution: you should still fully understand the code, the problem it solves, and the reasoning behind the approach you propose. In short, even if AI helped write it, the why should still be yours.
|
||||||
|
|
||||||
|
### Contributions beyond code
|
||||||
|
|
||||||
|
**Not technical? We need you too.**
|
||||||
|
Open source is much more than code. Writing documentation, improving onboarding, translating content, answering questions, reporting bugs, or simply helping others feel welcome all make a huge difference.
|
||||||
|
|
||||||
|
### Ways to contribute
|
||||||
|
|
||||||
|
When you’re ready, here are a few ways to get involved:
|
||||||
|
|
||||||
|
* 👋 **Say hello** and share your ideas with the community and maintainers on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org)
|
||||||
|
* 🛠️ **Open a PR** by following our guide to [develop La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md)
|
||||||
|
* 💡 **Suggest an idea** by opening a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md)
|
||||||
|
* 🐛 **Report a bug** by opening a [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
||||||
|
|
||||||
|
Thank you for helping build something open, useful, and human. 💙
|
||||||
|
|
||||||
|
### Community call
|
||||||
|
|
||||||
|
We host a community call on the first Friday of every month to share updates, discuss ideas, and connect with contributors.
|
||||||
|
|
||||||
|
Whether you’re actively contributing or just curious about the project, you’re welcome to join. More details are shared on the [Matrix channel](https://matrix.to/#/#meet-official:matrix.org).
|
||||||
|
|
||||||
## Philosophy
|
## Philosophy
|
||||||
|
|
||||||
|
|||||||
+11
-18
@@ -93,7 +93,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- resource-server
|
- resource-server
|
||||||
- default
|
- default
|
||||||
|
|
||||||
celery-dev:
|
celery-dev:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: meet:backend-development
|
image: meet:backend-development
|
||||||
@@ -237,29 +237,22 @@ services:
|
|||||||
- livekit-egress
|
- livekit-egress
|
||||||
|
|
||||||
livekit-egress:
|
livekit-egress:
|
||||||
image: livekit/egress:v1.11.0
|
image: livekit/egress:v1.11.0
|
||||||
environment:
|
environment:
|
||||||
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml
|
- ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml
|
||||||
- ./docker/livekit/out:/out
|
- ./docker/livekit/out:/out
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
|
||||||
metadata-collector-dev:
|
metadata-collector-dev:
|
||||||
build:
|
build:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
target: development
|
target: development
|
||||||
command: ["python", "metadata_collector.py", "dev"]
|
command: ["python", "metadata_collector.py", "dev"]
|
||||||
environment:
|
env_file:
|
||||||
- LIVEKIT_URL=ws://livekit:7880
|
- env.d/development/metadata_collector
|
||||||
- LIVEKIT_API_KEY=devkey
|
|
||||||
- LIVEKIT_API_SECRET=secret
|
|
||||||
- AWS_S3_ENDPOINT_URL=minio:9000
|
|
||||||
- AWS_S3_ACCESS_KEY_ID=meet
|
|
||||||
- AWS_S3_SECRET_ACCESS_KEY=password
|
|
||||||
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
|
||||||
- AWS_S3_SECURE_ACCESS=False
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/agents:/app
|
- ./src/agents:/app
|
||||||
- /app/.venv
|
- /app/.venv
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ USER root
|
|||||||
|
|
||||||
# Security patches for known CVEs
|
# Security patches for known CVEs
|
||||||
RUN apk update && apk upgrade \
|
RUN apk update && apk upgrade \
|
||||||
|
libcrypto3>=3.5.7-r0 \
|
||||||
|
libssl3>=3.5.7-r0 \
|
||||||
musl \
|
musl \
|
||||||
musl-utils \
|
musl-utils \
|
||||||
zlib>=1.3.2-r0 \
|
zlib>=1.3.2-r0 \
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ server {
|
|||||||
|
|
||||||
set $nonce $request_id;
|
set $nonce $request_id;
|
||||||
|
|
||||||
set $csp "upgrade-insecure-requests; ";
|
set $csp "default-src 'self'; upgrade-insecure-requests; ";
|
||||||
set $csp "${csp}frame-ancestors ${ms_domains}; ";
|
set $csp "${csp}frame-ancestors ${ms_domains}; ";
|
||||||
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
|
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
|
||||||
|
set $csp "${csp}style-src 'self' 'unsafe-inline'; ";
|
||||||
|
set $csp "${csp}img-src 'self' data:; ";
|
||||||
|
set $csp "${csp}font-src 'self' data:; ";
|
||||||
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
|
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
|
||||||
set $csp "${csp}frame-src 'none'; ";
|
set $csp "${csp}frame-src 'none'; ";
|
||||||
set $csp "${csp}object-src 'none'; ";
|
set $csp "${csp}object-src 'none'; ";
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ sequenceDiagram
|
|||||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||||
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
|
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
|
||||||
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
|
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
|
||||||
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). |
|
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). If `False`, fallback to LiveKit egress complete webhook. |
|
||||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ sequenceDiagram
|
|||||||
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
|
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
|
||||||
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
|
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
|
||||||
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
|
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
|
||||||
| whisperx_max_retries | Integer | `0` | Maximum number of retries for WhisperX API requests. |
|
|
||||||
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
|
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
|
||||||
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
|
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
|
||||||
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
|
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
|
||||||
|
|||||||
@@ -244,6 +244,69 @@ meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 44
|
|||||||
|
|
||||||
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
|
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
|
||||||
|
|
||||||
|
## Rebranding the favicon
|
||||||
|
|
||||||
|
The favicon is bundled into the frontend image and served as a set of static
|
||||||
|
files from `/usr/share/nginx/html` (`favicon.ico`, `favicon-16x16.png`,
|
||||||
|
`favicon-32x32.png`, `apple-touch-icon.png`, `android-chrome-192x192.png`,
|
||||||
|
`android-chrome-512x512.png`, `icon.png`). To rebrand without forking and
|
||||||
|
rebuilding the image, overlay your own icons onto those paths with a volume —
|
||||||
|
this serves the right icon from the first byte (no rebuild, no flash) and
|
||||||
|
covers every variant, including the iOS home-screen and Android/PWA icons.
|
||||||
|
|
||||||
|
Put your icons in a `ConfigMap` (`binaryData` keeps the PNGs intact)…
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: meet-favicon
|
||||||
|
binaryData:
|
||||||
|
# base64 of each replacement icon
|
||||||
|
favicon.ico: <base64…>
|
||||||
|
favicon-16x16.png: <base64…>
|
||||||
|
favicon-32x32.png: <base64…>
|
||||||
|
apple-touch-icon.png: <base64…>
|
||||||
|
android-chrome-192x192.png: <base64…>
|
||||||
|
android-chrome-512x512.png: <base64…>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# e.g. build the ConfigMap straight from a directory of icons
|
||||||
|
$ kubectl create configmap meet-favicon --from-file=./my-icons/
|
||||||
|
```
|
||||||
|
|
||||||
|
…then mount each file over the bundled one via the chart's
|
||||||
|
`frontend.extraVolumes` / `frontend.extraVolumeMounts` (the `subPath` mounts
|
||||||
|
the single file without hiding the rest of `html/`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
extraVolumes:
|
||||||
|
- name: favicon
|
||||||
|
configMap:
|
||||||
|
name: meet-favicon
|
||||||
|
extraVolumeMounts:
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/favicon.ico
|
||||||
|
subPath: favicon.ico
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/favicon-16x16.png
|
||||||
|
subPath: favicon-16x16.png
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/favicon-32x32.png
|
||||||
|
subPath: favicon-32x32.png
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/apple-touch-icon.png
|
||||||
|
subPath: apple-touch-icon.png
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/android-chrome-192x192.png
|
||||||
|
subPath: android-chrome-192x192.png
|
||||||
|
- name: favicon
|
||||||
|
mountPath: /usr/share/nginx/html/android-chrome-512x512.png
|
||||||
|
subPath: android-chrome-512x512.png
|
||||||
|
```
|
||||||
|
|
||||||
## All options
|
## All options
|
||||||
|
|
||||||
These are the environmental options available on meet backend.
|
These are the environmental options available on meet backend.
|
||||||
@@ -344,7 +407,7 @@ These are the environmental options available on meet backend.
|
|||||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||||
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
||||||
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
|
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events. If false, fallback to egress webhook. | false |
|
||||||
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
||||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||||
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
|||||||
LIVEKIT_API_SECRET=secret
|
LIVEKIT_API_SECRET=secret
|
||||||
LIVEKIT_API_KEY=devkey
|
LIVEKIT_API_KEY=devkey
|
||||||
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
|
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
|
||||||
|
LIVEKIT_INTERNAL_URL=http://livekit:7880
|
||||||
LIVEKIT_VERIFY_SSL=False
|
LIVEKIT_VERIFY_SSL=False
|
||||||
ALLOW_UNREGISTERED_ROOMS=False
|
ALLOW_UNREGISTERED_ROOMS=False
|
||||||
|
|
||||||
@@ -86,6 +87,9 @@ ROOM_TELEPHONY_ENABLED=True
|
|||||||
# Metadata
|
# Metadata
|
||||||
METADATA_COLLECTOR_ENABLED=True
|
METADATA_COLLECTOR_ENABLED=True
|
||||||
|
|
||||||
|
# Subtitle
|
||||||
|
ROOM_SUBTITLE_ENABLED=False
|
||||||
|
|
||||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||||
|
|
||||||
@@ -94,3 +98,4 @@ EXTERNAL_API_ENABLED=True
|
|||||||
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
|
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
|
||||||
APPLICATION_JWT_SECRET_KEY=devKey
|
APPLICATION_JWT_SECRET_KEY=devKey
|
||||||
APPLICATION_BASE_URL=http://localhost:3000
|
APPLICATION_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
LIVEKIT_URL=ws://livekit:7880
|
||||||
|
LIVEKIT_API_KEY=devkey
|
||||||
|
LIVEKIT_API_SECRET=secret
|
||||||
|
|
||||||
|
AWS_S3_ENDPOINT_URL=minio:9000
|
||||||
|
AWS_S3_ACCESS_KEY_ID=meet
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY=password
|
||||||
|
AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||||
|
AWS_S3_SECURE_ACCESS=False
|
||||||
@@ -2,8 +2,10 @@ LIVEKIT_URL=ws://livekit:7880
|
|||||||
LIVEKIT_API_KEY=devkey
|
LIVEKIT_API_KEY=devkey
|
||||||
LIVEKIT_API_SECRET=secret
|
LIVEKIT_API_SECRET=secret
|
||||||
|
|
||||||
STT_PROVIDER=kyutai
|
STT_PROVIDER=kyutai # kyutai, deepgram
|
||||||
ENABLE_SILERO_VAD=False
|
ENABLE_SILERO_VAD=False
|
||||||
|
|
||||||
|
DEEPGRAM_API_KEY=
|
||||||
|
|
||||||
KYUTAI_STT_BASE_URL=
|
KYUTAI_STT_BASE_URL=
|
||||||
KYUTAI_API_KEY=
|
KYUTAI_API_KEY=
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
APP_NAME="meet-app-summary-dev"
|
APP_NAME="meet-app-summary-dev"
|
||||||
APP_API_TOKEN="password"
|
APP_API_TOKEN="password"
|
||||||
|
|
||||||
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||||
AWS_S3_SECURE_ACCESS=false
|
AWS_S3_SECURE_ACCESS=false
|
||||||
|
|
||||||
@@ -20,5 +20,13 @@ LLM_MODEL="albert-large"
|
|||||||
WEBHOOK_API_TOKEN="secret"
|
WEBHOOK_API_TOKEN="secret"
|
||||||
WEBHOOK_URL="https://configure-your-url.com"
|
WEBHOOK_URL="https://configure-your-url.com"
|
||||||
|
|
||||||
|
IS_RESOLVE_SPEAKER_IDENTITIES_ENABLED=true
|
||||||
|
RESOLVE_SPEAKER_IDENTITIES_DEFAULT_OVERLAP=0.5
|
||||||
|
RESOLVE_SPEAKER_ENABLE_SPLIT_ON_WORDS=true
|
||||||
|
RESOLVE_SPEAKER_MAX_WORD_DURATION=1
|
||||||
|
|
||||||
POSTHOG_API_KEY="your-posthog-key"
|
POSTHOG_API_KEY="your-posthog-key"
|
||||||
POSTHOG_ENABLED="False"
|
POSTHOG_ENABLED="False"
|
||||||
|
|
||||||
|
# Transcription
|
||||||
|
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
||||||
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
||||||
<Version>0.0.1.0</Version>
|
<Version>0.0.2.0</Version>
|
||||||
<ProviderName>__APP_NAME__</ProviderName>
|
<ProviderName>__APP_NAME__</ProviderName>
|
||||||
<DefaultLocale>fr-FR</DefaultLocale>
|
<DefaultLocale>fr-FR</DefaultLocale>
|
||||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
<DisplayName DefaultValue="__APP_NAME__"/>
|
||||||
@@ -87,9 +87,9 @@
|
|||||||
<Description resid="GenerateLink.Tooltip"/>
|
<Description resid="GenerateLink.Tooltip"/>
|
||||||
</Supertip>
|
</Supertip>
|
||||||
<Icon>
|
<Icon>
|
||||||
<bt:Image size="16" resid="Add.16x16"/>
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
<bt:Image size="32" resid="Add.32x32"/>
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
<bt:Image size="80" resid="Add.80x80"/>
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
</Icon>
|
</Icon>
|
||||||
<Action xsi:type="ExecuteFunction">
|
<Action xsi:type="ExecuteFunction">
|
||||||
<FunctionName>generateMeetingLinkFromMail</FunctionName>
|
<FunctionName>generateMeetingLinkFromMail</FunctionName>
|
||||||
@@ -126,9 +126,9 @@
|
|||||||
<Description resid="GenerateLink.Tooltip"/>
|
<Description resid="GenerateLink.Tooltip"/>
|
||||||
</Supertip>
|
</Supertip>
|
||||||
<Icon>
|
<Icon>
|
||||||
<bt:Image size="16" resid="Add.16x16"/>
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
<bt:Image size="32" resid="Add.32x32"/>
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
<bt:Image size="80" resid="Add.80x80"/>
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
</Icon>
|
</Icon>
|
||||||
<Action xsi:type="ExecuteFunction">
|
<Action xsi:type="ExecuteFunction">
|
||||||
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
||||||
@@ -175,15 +175,34 @@
|
|||||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
|
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||||
</bt:Urls>
|
</bt:Urls>
|
||||||
<bt:ShortStrings>
|
<bt:ShortStrings>
|
||||||
|
<!-- Default (French) -->
|
||||||
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
||||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
|
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
|
||||||
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
|
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
|
||||||
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
|
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
|
||||||
|
<bt:Override Locale="en-US" Value="Open settings"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
|
||||||
|
<bt:Override Locale="en-US" Value="Settings"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="Einstellungen"/>
|
||||||
|
</bt:String>
|
||||||
</bt:ShortStrings>
|
</bt:ShortStrings>
|
||||||
<bt:LongStrings>
|
<bt:LongStrings>
|
||||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
|
||||||
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement."/>
|
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
|
||||||
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
|
||||||
|
</bt:String>
|
||||||
</bt:LongStrings>
|
</bt:LongStrings>
|
||||||
</Resources>
|
</Resources>
|
||||||
</VersionOverrides>
|
</VersionOverrides>
|
||||||
|
|||||||
Generated
+65
-28
@@ -9,33 +9,35 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.49.0",
|
"core-js": "3.49.0",
|
||||||
"regenerator-runtime": "^0.14.1"
|
"i18next": "26.3.1",
|
||||||
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.0",
|
"@babel/core": "7.29.0",
|
||||||
"@babel/preset-env": "^7.25.4",
|
"@babel/preset-env": "7.29.0",
|
||||||
"@types/office-js": "^1.0.377",
|
"@types/office-js": "1.0.582",
|
||||||
"@types/office-runtime": "^1.0.35",
|
"@types/office-runtime": "1.0.36",
|
||||||
"acorn": "^8.11.3",
|
"acorn": "8.16.0",
|
||||||
"babel-loader": "^9.1.3",
|
"babel-loader": "9.2.1",
|
||||||
"copy-webpack-plugin": "^14.0.0",
|
"copy-webpack-plugin": "14.0.0",
|
||||||
"eslint-plugin-office-addins": "^4.0.3",
|
"eslint-plugin-office-addins": "4.0.6",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "6.2.0",
|
||||||
"html-loader": "^5.0.0",
|
"html-loader": "5.1.0",
|
||||||
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
"html-webpack-inject-attributes-plugin": "1.0.6",
|
||||||
"html-webpack-plugin": "^5.6.0",
|
"html-webpack-plugin": "5.6.6",
|
||||||
"office-addin-cli": "^2.0.3",
|
"office-addin-cli": "2.0.6",
|
||||||
"office-addin-debugging": "^6.0.3",
|
"office-addin-debugging": "6.0.6",
|
||||||
"office-addin-dev-certs": "^2.0.3",
|
"office-addin-dev-certs": "2.0.6",
|
||||||
"office-addin-lint": "^3.0.3",
|
"office-addin-lint": "3.0.6",
|
||||||
"office-addin-manifest": "^2.0.3",
|
"office-addin-manifest": "2.1.2",
|
||||||
"office-addin-prettier-config": "^2.0.1",
|
"office-addin-prettier-config": "2.0.1",
|
||||||
"os-browserify": "^0.3.0",
|
"os-browserify": "0.3.0",
|
||||||
"process": "^0.11.10",
|
"process": "0.11.10",
|
||||||
"source-map-loader": "^5.0.0",
|
"source-map-loader": "5.0.0",
|
||||||
"webpack": "^5.95.0",
|
"webpack": "5.105.4",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "5.1.4",
|
||||||
"webpack-dev-server": "5.2.4"
|
"webpack-dev-server": "5.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2111,9 +2113,7 @@
|
|||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
@@ -9363,6 +9363,43 @@
|
|||||||
"node": ">=10.18"
|
"node": ">=10.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "26.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||||
|
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com/i18next"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5 || ^6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next-browser-languagedetector": {
|
||||||
|
"version": "8.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||||
|
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.23.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -15213,7 +15250,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
|||||||
@@ -26,33 +26,35 @@
|
|||||||
"watch": "webpack --mode development --watch"
|
"watch": "webpack --mode development --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.49.0",
|
"core-js": "3.49.0",
|
||||||
"regenerator-runtime": "^0.14.1"
|
"i18next": "26.3.1",
|
||||||
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.0",
|
"@babel/core": "7.29.0",
|
||||||
"@babel/preset-env": "^7.25.4",
|
"@babel/preset-env": "7.29.0",
|
||||||
"@types/office-js": "^1.0.377",
|
"@types/office-js": "1.0.582",
|
||||||
"@types/office-runtime": "^1.0.35",
|
"@types/office-runtime": "1.0.36",
|
||||||
"acorn": "^8.11.3",
|
"acorn": "8.16.0",
|
||||||
"babel-loader": "^9.1.3",
|
"babel-loader": "9.2.1",
|
||||||
"copy-webpack-plugin": "^14.0.0",
|
"copy-webpack-plugin": "14.0.0",
|
||||||
"eslint-plugin-office-addins": "^4.0.3",
|
"eslint-plugin-office-addins": "4.0.6",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "6.2.0",
|
||||||
"html-loader": "^5.0.0",
|
"html-loader": "5.1.0",
|
||||||
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
"html-webpack-inject-attributes-plugin": "1.0.6",
|
||||||
"html-webpack-plugin": "^5.6.0",
|
"html-webpack-plugin": "5.6.6",
|
||||||
"office-addin-cli": "^2.0.3",
|
"office-addin-cli": "2.0.6",
|
||||||
"office-addin-debugging": "^6.0.3",
|
"office-addin-debugging": "6.0.6",
|
||||||
"office-addin-dev-certs": "^2.0.3",
|
"office-addin-dev-certs": "2.0.6",
|
||||||
"office-addin-lint": "^3.0.3",
|
"office-addin-lint": "3.0.6",
|
||||||
"office-addin-manifest": "^2.0.3",
|
"office-addin-manifest": "2.1.2",
|
||||||
"office-addin-prettier-config": "^2.0.1",
|
"office-addin-prettier-config": "2.0.1",
|
||||||
"os-browserify": "^0.3.0",
|
"os-browserify": "0.3.0",
|
||||||
"process": "^0.11.10",
|
"process": "0.11.10",
|
||||||
"source-map-loader": "^5.0.0",
|
"source-map-loader": "5.0.0",
|
||||||
"webpack": "^5.95.0",
|
"webpack": "5.105.4",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "5.1.4",
|
||||||
"webpack-dev-server": "5.2.4"
|
"webpack-dev-server": "5.2.4"
|
||||||
},
|
},
|
||||||
"prettier": "office-addin-prettier-config",
|
"prettier": "office-addin-prettier-config",
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
/* global Office */
|
/* global Office */
|
||||||
|
const { APP_NAME } = require("../common/index");
|
||||||
const { createRoom, initSession } = require("../common/api");
|
const { createRoom, initSession } = require("../common/api");
|
||||||
const { startPolling } = require("../common/polling");
|
const { startPolling } = require("../common/polling");
|
||||||
const { saveSession, loadSession } = require("../common/session");
|
const { saveSession, loadSession } = require("../common/session");
|
||||||
const { openTransitDialog } = require("../common/transitDialog");
|
const { openTransitDialog } = require("../common/transitDialog");
|
||||||
const { buildMeetingMessage } = require("../common/messageBuilder");
|
const { buildMeetingMessage } = require("../common/messageBuilder");
|
||||||
const { applyAppName } = require("../common/helpers");
|
const { applyAppName } = require("../common/helpers");
|
||||||
|
const { initI18n, t } = require("../common/i18n");
|
||||||
|
const { isMeetingAlreadyAdded } = require("../common/meetingDetector");
|
||||||
|
|
||||||
|
Office.onReady(async function (info) {
|
||||||
|
|
||||||
|
await initI18n()
|
||||||
|
|
||||||
Office.onReady(function (info) {
|
|
||||||
if (info.host === Office.HostType.Outlook) {
|
if (info.host === Office.HostType.Outlook) {
|
||||||
applyAppName();
|
applyAppName();
|
||||||
}
|
}
|
||||||
@@ -22,41 +28,52 @@ function notify(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function insertMeetingLink(event, session) {
|
function insertMeetingLink(event, session) {
|
||||||
|
const item = Office.context.mailbox.item;
|
||||||
|
|
||||||
|
isMeetingAlreadyAdded(item)
|
||||||
|
.then((alreadyAdded) => {
|
||||||
|
if (alreadyAdded) {
|
||||||
|
notify(t("meeting.already_added", { app_name: APP_NAME }));
|
||||||
|
event.completed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return _doInsertMeetingLink(event, session);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
notify(t("meeting.error.details", { message: err.message }));
|
||||||
|
event.completed();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _doInsertMeetingLink(event, session) {
|
||||||
createRoom(session)
|
createRoom(session)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const { url, message } = buildMeetingMessage(data);
|
const isWeb = Office.context.diagnostics.platform === "OfficeOnline";
|
||||||
|
const { url, text } = buildMeetingMessage(data, isWeb);
|
||||||
const item = Office.context.mailbox.item;
|
const item = Office.context.mailbox.item;
|
||||||
|
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => {
|
||||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
notify(`Erreur de lecture : ${getResult.error.message}`);
|
notify(t("meeting.error.details", { message: setResult.error.message }));
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBody = getResult.value + message;
|
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
||||||
item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
|
notify(t("meeting.link_inserted"));
|
||||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
resolve();
|
||||||
notify(`Erreur d'insertion : ${setResult.error.message}`);
|
return;
|
||||||
resolve();
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
item.location.setAsync(url, (locationResult) => {
|
||||||
notify("Lien de réunion inséré !");
|
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
resolve();
|
notify(t("meeting.error.details", { message: locationResult.error.message }));
|
||||||
return;
|
} else {
|
||||||
|
notify(t("meeting.link_inserted"));
|
||||||
}
|
}
|
||||||
|
resolve();
|
||||||
item.location.setAsync(url, (locationResult) => {
|
|
||||||
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
notify(`Erreur de localisation : ${locationResult.error.message}`);
|
|
||||||
} else {
|
|
||||||
notify("Lien de réunion inséré !");
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -79,11 +96,11 @@ function connect(event) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onTimeout: () => {
|
onTimeout: () => {
|
||||||
notify("Connexion expirée, veuillez réessayer.");
|
notify(t("meeting.error.auth"));
|
||||||
event.completed();
|
event.completed();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
notify("Une erreur est survenue, veuillez ré-essayer");
|
notify(t("meeting.error.retry"));
|
||||||
event.completed();
|
event.completed();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -99,7 +116,7 @@ function connect(event) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
notify(`Erreur : ${err.message}`);
|
notify(t("meeting.error.details", { message: err.message }));
|
||||||
event.completed();
|
event.completed();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const { APP_NAME } = require("../common");
|
||||||
|
|
||||||
|
const i18nextModule = require("i18next");
|
||||||
|
const i18next = i18nextModule.default || i18nextModule;
|
||||||
|
|
||||||
|
const fr = require("../locales/fr/translation.json");
|
||||||
|
const en = require("../locales/en/translation.json");
|
||||||
|
const de = require("../locales/de/translation.json");
|
||||||
|
|
||||||
|
async function initI18n() {
|
||||||
|
const lng = typeof Office !== "undefined" ? Office.context.displayLanguage : navigator.language;
|
||||||
|
|
||||||
|
await i18next.init({
|
||||||
|
lng,
|
||||||
|
fallbackLng: "fr",
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
resources: {
|
||||||
|
fr: { translation: fr },
|
||||||
|
en: { translation: en },
|
||||||
|
de: { translation: de },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(key, vars) {
|
||||||
|
return i18next.t(key, vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateUI() {
|
||||||
|
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||||
|
const key = el.getAttribute("data-i18n");
|
||||||
|
el.textContent = t(key, { app_name: APP_NAME });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-i18n-attr]").forEach((el) => {
|
||||||
|
const pairs = el.getAttribute("data-i18n-attr").split(",");
|
||||||
|
pairs.forEach((pair) => {
|
||||||
|
const [attr, key] = pair.split(":");
|
||||||
|
el.setAttribute(attr, t(key, { app_name: APP_NAME }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-i18n-aria]").forEach((el) => {
|
||||||
|
el.setAttribute("aria-label", t(el.getAttribute("data-i18n-aria")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { initI18n, t, translateUI };
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
|
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
|
||||||
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
|
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
|
||||||
|
const ENABLE_SOURCE_TRACKING = window.__APP_CONFIG__?.ENABLE_SOURCE_TRACKING === "true";
|
||||||
|
const FEEDBACK_FORM = window.__APP_CONFIG__?.FEEDBACK_FORM || null;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
|
ENABLE_SOURCE_TRACKING,
|
||||||
|
FEEDBACK_FORM
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
const { BASE_URL } = require("./index");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a promise that resolves to true if a meeting link is already present
|
||||||
|
*/
|
||||||
|
function isMeetingAlreadyAdded(item) {
|
||||||
|
return Promise.all([_checkBody(item), _checkLocation(item)]).then(
|
||||||
|
([inBody, inLocation]) => inBody || inLocation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _checkBody(item) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
item.body.getAsync(Office.CoercionType.Text, (result) => {
|
||||||
|
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(_containsMeetingUrl(result.value));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _checkLocation(item) {
|
||||||
|
// Location only exists on appointments
|
||||||
|
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
item.location.getAsync((result) => {
|
||||||
|
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(_containsMeetingUrl(result.value));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _containsMeetingUrl(text) {
|
||||||
|
if (!text) return false;
|
||||||
|
return text.includes(BASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMeetingLink(item) {
|
||||||
|
return Promise.all([_removeFromBody(item), _removeFromLocation(item)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _removeFromBody(item) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
item.body.getAsync(Office.CoercionType.Html, (result) => {
|
||||||
|
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cleaned = _cleanBody(result.value || "");
|
||||||
|
if (cleaned === null) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
item.body.setAsync(cleaned, { coercionType: Office.CoercionType.Html }, () => resolve());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _removeFromLocation(item) {
|
||||||
|
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
item.location.getAsync((result) => {
|
||||||
|
if (
|
||||||
|
result.status === Office.AsyncResultStatus.Succeeded &&
|
||||||
|
_containsMeetingUrl(result.value)
|
||||||
|
) {
|
||||||
|
item.location.setAsync("", () => resolve());
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEPARATOR = /─{10,}/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns cleaned HTML, or null if no meeting block was found.
|
||||||
|
*/
|
||||||
|
function _cleanBody(html) {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
|
||||||
|
const hits = [];
|
||||||
|
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
||||||
|
while (walker.nextNode()) {
|
||||||
|
if (SEPARATOR.test(walker.currentNode.nodeValue)) hits.push(walker.currentNode);
|
||||||
|
}
|
||||||
|
if (hits.length < 2) return null;
|
||||||
|
|
||||||
|
const range = doc.createRange();
|
||||||
|
range.setStartBefore(hits[0]);
|
||||||
|
range.setEndAfter(hits[hits.length - 1]);
|
||||||
|
range.deleteContents();
|
||||||
|
|
||||||
|
return doc.documentElement.outerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isMeetingAlreadyAdded, removeMeetingLink };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const { APP_NAME } = require("./index");
|
const { APP_NAME, ENABLE_SOURCE_TRACKING } = require("./index");
|
||||||
|
const { t } = require("./i18n");
|
||||||
|
|
||||||
function _formatPin(pin) {
|
function _formatPin(pin) {
|
||||||
if (!pin) return "";
|
if (!pin) return "";
|
||||||
@@ -20,33 +21,59 @@ function _formatPhone(phone) {
|
|||||||
return clean;
|
return clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _appendTrackingParams(url) {
|
||||||
|
if (!ENABLE_SOURCE_TRACKING) return url;
|
||||||
|
const u = new URL(url);
|
||||||
|
u.searchParams.set("from", "outlook-addin");
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// todo - escape html / link
|
// todo - escape html / link
|
||||||
function buildMeetingMessage(data) {
|
function buildMeetingMessage(data, isWeb) {
|
||||||
if (!data?.url) {
|
if (!data?.url) {
|
||||||
throw new Error("buildMeetingMessage: missing url in data");
|
throw new Error("buildMeetingMessage: missing url in data");
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = data.url;
|
const url = _appendTrackingParams(data.url);
|
||||||
const phone = _formatPhone(data.telephony?.phone_number);
|
const phone = _formatPhone(data.telephony?.phone_number);
|
||||||
const pin = _formatPin(data.telephony?.pin_code);
|
const pin = _formatPin(data.telephony?.pin_code);
|
||||||
|
|
||||||
const telephonyBlock =
|
let textLines = "";
|
||||||
phone && pin
|
let phoneLines = [];
|
||||||
? `
|
|
||||||
|
|
||||||
Ou appelez (audio uniquement)
|
const join = t("meeting_message.join", { app_name: APP_NAME });
|
||||||
(FR) ${phone}
|
const phoneOnly = t("meeting_message.phone_only");
|
||||||
Code : ${pin}`
|
const phoneFr = t("meeting_message.phone_fr", { phone });
|
||||||
: "";
|
const pinCode = t("meeting_message.pin_code", { pin });
|
||||||
|
|
||||||
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
|
if (isWeb) {
|
||||||
────────────────────────────────────────
|
phoneLines = phone && pin ? [`<br><br>${phoneOnly}`, `<br>${phoneFr}`, `<br>${pinCode}`] : [];
|
||||||
Rejoindre la réunion ${APP_NAME}
|
|
||||||
|
|
||||||
<a href="${url}">${url}</a>${telephonyBlock}
|
textLines = [
|
||||||
────────────────────────────────────────</pre>`;
|
"<br><br>────────────────────────────────────────",
|
||||||
|
`<br>${join}`,
|
||||||
|
`<br><br><a href="${url}" target="_blank">${url}</a>`,
|
||||||
|
...phoneLines,
|
||||||
|
"<br>────────────────────────────────────────<br>",
|
||||||
|
];
|
||||||
|
|
||||||
return { url, message };
|
} else {
|
||||||
|
|
||||||
|
phoneLines = phone && pin ? [`\n\n${phoneOnly}`, `\n${phoneFr}`, `\n${pinCode}`] : [];
|
||||||
|
|
||||||
|
textLines = [
|
||||||
|
"\n\n────────────────────────────────────────",
|
||||||
|
`\n${join}`,
|
||||||
|
`\n\n${url}`,
|
||||||
|
...phoneLines,
|
||||||
|
"\n────────────────────────────────────────\n",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = textLines.join("");
|
||||||
|
|
||||||
|
return { url, text };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { buildMeetingMessage };
|
module.exports = { buildMeetingMessage };
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"sideload": "Laden Sie das Add-In.",
|
||||||
|
"loading": "Wird geladen..."
|
||||||
|
},
|
||||||
|
"unauth": {
|
||||||
|
"intro": "Fügen Sie Ihren Outlook-Terminen ganz einfach einen {{app_name}}-Besprechungslink hinzu.",
|
||||||
|
"proconnect_btn": "Aanmelden met ProConnect",
|
||||||
|
"proconnect_link": "Wat is ProConnect?",
|
||||||
|
"proconnect_link_title": "Wat is ProConnect? - nieuw venster"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"close_window": "Falls sich dieses Fenster nicht automatisch schließt, schließen Sie es bitte manuell."
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"disconnect": "Abmelden"
|
||||||
|
},
|
||||||
|
"meeting": {
|
||||||
|
"already_added": "Es wurde bereits ein {{app_name}}-Meeting hinzugefügt.",
|
||||||
|
"link_inserted": "Besprechungslink erfolgreich eingefügt",
|
||||||
|
"generating": "Wird erstellt...",
|
||||||
|
"add_meeting": "{{app_name}}-Besprechung hinzufügen",
|
||||||
|
"remove_meeting": "{{app_name}}-Besprechung entfernen",
|
||||||
|
"removing": "Wird entfernt...",
|
||||||
|
"error": {
|
||||||
|
"auth": "Ihre Sitzung ist abgelaufen. Bitte versuchen Sie es erneut.",
|
||||||
|
"retry": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
|
||||||
|
"details": "Fehler: {{message}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meeting_message": {
|
||||||
|
"join": "An der {{app_name}}-Besprechung teilnehmen",
|
||||||
|
"phone_only": "Oder per Telefon teilnehmen (nur Audio)",
|
||||||
|
"phone_fr": "(FR) {{phone}}",
|
||||||
|
"pin_code": "Code {{pin}}"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"feedback": "Teilen Sie uns Ihr Feedback mit"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"sideload": "Please load the add-in.",
|
||||||
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"unauth": {
|
||||||
|
"intro": "Easily add a {{app_name}} meeting link to your Outlook events.",
|
||||||
|
"proconnect_btn": "Sign in with ProConnect",
|
||||||
|
"proconnect_link": "What is ProConnect?",
|
||||||
|
"proconnect_link_title": "What is ProConnect? - new window"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"close_window": "If this window does not close automatically, please close it manually."
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"disconnect": "Sign out"
|
||||||
|
},
|
||||||
|
"meeting": {
|
||||||
|
"already_added": "A {{app_name}} meeting has already been added.",
|
||||||
|
"link_inserted": "Meeting link inserted successfully",
|
||||||
|
"generating": "Generating...",
|
||||||
|
"add_meeting": "Add a {{app_name}} meeting",
|
||||||
|
"remove_meeting": "Remove the {{app_name}} meeting",
|
||||||
|
"removing": "Removing...",
|
||||||
|
"error": {
|
||||||
|
"auth": "Your session has expired. Please try again.",
|
||||||
|
"retry": "An error occurred. Please try again.",
|
||||||
|
"details": "Error: {{message}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meeting_message": {
|
||||||
|
"join": "Join the {{app_name}} meeting",
|
||||||
|
"phone_only": "Or call in (audio only)",
|
||||||
|
"phone_fr": "(FR) {{phone}}",
|
||||||
|
"pin_code": "Code {{pin}}"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"feedback": "Share your feedback"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"sideload": "Veuillez charger le complément.",
|
||||||
|
"loading": "Chargement..."
|
||||||
|
},
|
||||||
|
"unauth": {
|
||||||
|
"intro": "Ajoutez facilement un lien de réunion {{app_name}} à vos événements Outlook.",
|
||||||
|
"proconnect_btn": "S'identifier avec ProConnect",
|
||||||
|
"proconnect_link": "Qu'est-ce que ProConnect ?",
|
||||||
|
"proconnect_link_title": "Qu'est-ce que ProConnect ? - nouvelle fenêtre"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"close_window": "Si cette fenêtre ne se ferme pas toute seule, veuillez la fermer manuellement."
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"disconnect": "Se déconnecter"
|
||||||
|
},
|
||||||
|
"meeting": {
|
||||||
|
"already_added": "Une réunion {{app_name}} a déjà été ajoutée.",
|
||||||
|
"link_inserted": "Lien de réunion inséré avec succès",
|
||||||
|
"generating": "Génération...",
|
||||||
|
"add_meeting": "Ajouter une réunion {{app_name}}",
|
||||||
|
"remove_meeting": "Supprimer la réunion {{app_name}}",
|
||||||
|
"removing": "Suppression en cours...",
|
||||||
|
"error": {
|
||||||
|
"auth": "Connexion expirée, veuillez réessayer.",
|
||||||
|
"retry": "Une erreur est survenue, veuillez ré-essayer",
|
||||||
|
"details": "Erreur : {{message}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meeting_message": {
|
||||||
|
"join": "Rejoindre la réunion {{app_name}}",
|
||||||
|
"phone_only": "Ou appelez (audio uniquement)",
|
||||||
|
"phone_fr": "(FR) {{phone}}",
|
||||||
|
"pin_code": "Code {{pin}}"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"feedback": "Partagez-nous vos retours"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
<div id="sideload-msg" data-i18n="app.sideload"></div>
|
||||||
<div class="spinner-container"
|
<div class="spinner-container"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-label="Chargement..."
|
data-i18n-aria="app.loading"
|
||||||
>
|
>
|
||||||
<svg class="spinner-svg"
|
<svg class="spinner-svg"
|
||||||
viewBox="0 0 28 28"
|
viewBox="0 0 28 28"
|
||||||
@@ -40,5 +40,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="close-msg" style="display: none; text-align: center; font-size: 13px; color: #666; margin-top: 16px;" data-i18n="success.close_window"></p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
const { applyAppName } = require("../common/helpers");
|
const { applyAppName } = require("../common/helpers");
|
||||||
const { exchangeSession } = require("../common/api");
|
const { exchangeSession } = require("../common/api");
|
||||||
const { consume } = require("../common/transitToken");
|
const { consume } = require("../common/transitToken");
|
||||||
|
const { initI18n, translateUI } = require("../common/i18n");
|
||||||
|
|
||||||
applyAppName();
|
(async () => {
|
||||||
|
await initI18n();
|
||||||
|
|
||||||
const transitToken = consume();
|
applyAppName();
|
||||||
|
translateUI();
|
||||||
|
|
||||||
if (!transitToken) {
|
const transitToken = consume();
|
||||||
console.error("Transit token not found in sessionStorage");
|
|
||||||
window.close();
|
if (!transitToken) {
|
||||||
} else {
|
console.error("Transit token not found in sessionStorage");
|
||||||
exchangeSession(transitToken)
|
window.close();
|
||||||
.catch((e) => {
|
} else {
|
||||||
console.error(`Error occured: ${e}`);
|
exchangeSession(transitToken)
|
||||||
})
|
.then(() => {
|
||||||
.finally(() => {
|
document.querySelector(".spinner-container").style.display = "none";
|
||||||
window.close();
|
document.querySelector("#close-msg").style.display = "block";
|
||||||
});
|
})
|
||||||
}
|
.catch((e) => {
|
||||||
|
console.error(`Error occured: ${e}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// NOTE: doesn't work with the desktop client — the browser considers
|
||||||
|
// this window wasn't opened by this script (it was opened externally),
|
||||||
|
// so it blocks window.close() for security reasons. The "#close-msg"
|
||||||
|
// shown above is the fallback for that case.g
|
||||||
|
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
@@ -115,15 +115,39 @@ button {
|
|||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Danger button (remove meeting) ── */
|
||||||
|
|
||||||
|
#btn-remove {
|
||||||
|
background-color: #CA3632; /* error.400 */
|
||||||
|
color: #FFFFFF;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-remove:hover {
|
||||||
|
background-color: #EE6A66; /* error.600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-remove:active {
|
||||||
|
background-color: #F28D8A; /* error.700 */
|
||||||
|
color: #F6AFAD; /* error.200 */
|
||||||
|
}
|
||||||
|
|
||||||
|
#btn-remove:disabled {
|
||||||
|
background-color: #F6AFAD; /* error.800 */
|
||||||
|
color: #FAD2D1; /* error.900 */
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Version ── */
|
/* ── Version ── */
|
||||||
|
|
||||||
#version-tag {
|
#version-tag {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
@@ -131,9 +155,29 @@ button {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#feedback-link {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
color: #6b7280;
|
||||||
|
text-decoration: underline;
|
||||||
|
pointer-events: all; /* override parent's pointer-events: none */
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#feedback-link:hover {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer-right {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.version-badge {
|
.version-badge {
|
||||||
background: #fef3c7;
|
background: #EEF1F4;
|
||||||
color: #92400e;
|
color: #2845C1;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -10,47 +10,56 @@
|
|||||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
<div id="sideload-msg" data-i18n="app.sideload"></div>
|
||||||
|
|
||||||
<div id="app-body">
|
<div id="app-body">
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div id="view-loading">
|
<div id="view-loading">
|
||||||
<p class="intro-text">Chargement...</p>
|
<p class="intro-text" data-i18n="app.loading"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unauthenticated -->
|
<!-- Unauthenticated -->
|
||||||
<div id="view-unauth" style="display:none;">
|
<div id="view-unauth" style="display:none;">
|
||||||
<p class="intro-text">
|
<p class="intro-text">
|
||||||
<span>Ajoutez facilement un lien de réunion <span data-app-name></span> à vos événements Outlook.</span>
|
<span data-i18n="unauth.intro"></span>
|
||||||
</p>
|
</p>
|
||||||
<hr class="divider" />
|
<hr class="divider" />
|
||||||
<button class="proconnect-button" id="btn-connect">
|
<button class="proconnect-button" id="btn-connect">
|
||||||
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
|
<span class="proconnect-sr-only" data-i18n="unauth.proconnect_btn"></span>
|
||||||
</button>
|
</button>
|
||||||
<p>
|
<p>
|
||||||
<a
|
<a href="https://www.proconnect.gouv.fr/"
|
||||||
href="https://www.proconnect.gouv.fr/"
|
target="_blank"
|
||||||
target="_blank"
|
rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer"
|
data-i18n-attr="title:unauth.proconnect_link_title"
|
||||||
title="Qu’est-ce que ProConnect ? - nouvelle fenêtre"
|
data-i18n="unauth.proconnect_link"
|
||||||
>
|
></a>
|
||||||
Qu’est-ce que ProConnect ?
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Authenticated -->
|
<!-- Authenticated -->
|
||||||
<div id="view-auth" style="display:none;">
|
<div id="view-auth" style="display:none;">
|
||||||
<div id="btn-container">
|
<div id="btn-container">
|
||||||
<button id="btn-generate">Ajouter une réunion <span data-app-name></span></button>
|
<!-- shown when no meeting is present -->
|
||||||
<button id="btn-disconnect">Se déconnecter</button>
|
<button id="btn-generate" data-i18n="meeting.add_meeting"></button>
|
||||||
|
<!-- shown when a meeting is already present -->
|
||||||
|
<button id="btn-remove" style="display:none;" data-i18n="meeting.remove_meeting"></button>
|
||||||
|
<button id="btn-disconnect" data-i18n="auth.disconnect"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer id="version-tag">
|
<footer id="version-tag">
|
||||||
<span class="version-badge">alpha</span>
|
<a id="feedback-link"
|
||||||
<span class="version-number">0.0.1</span>
|
style="display:none;"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
data-i18n="footer.feedback"
|
||||||
|
></a>
|
||||||
|
<div id="footer-right">
|
||||||
|
<span class="version-badge">beta</span>
|
||||||
|
<span class="version-number">0.0.2</span>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,22 +1,77 @@
|
|||||||
const { APP_NAME } = require("../common");
|
/* global Office */
|
||||||
|
const { APP_NAME, FEEDBACK_FORM } = require("../common");
|
||||||
const { applyAppName } = require("../common/helpers");
|
const { applyAppName } = require("../common/helpers");
|
||||||
const { initSession, createRoom } = require("../common/api");
|
const { initSession, createRoom } = require("../common/api");
|
||||||
const { startPolling } = require("../common/polling");
|
const { startPolling } = require("../common/polling");
|
||||||
const { openTransitDialog } = require("../common/transitDialog");
|
const { openTransitDialog } = require("../common/transitDialog");
|
||||||
const { loadSession, saveSession, clearSession } = require("../common/session");
|
const { loadSession, saveSession, clearSession } = require("../common/session");
|
||||||
|
|
||||||
const { buildMeetingMessage } = require("../common/messageBuilder");
|
const { buildMeetingMessage } = require("../common/messageBuilder");
|
||||||
|
const { initI18n, t, translateUI } = require("../common/i18n");
|
||||||
|
const { isMeetingAlreadyAdded, removeMeetingLink } = require("../common/meetingDetector");
|
||||||
|
|
||||||
|
// ── Views ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// todo - support loading view while polling
|
|
||||||
// todo - support error view
|
|
||||||
function showView(name) {
|
function showView(name) {
|
||||||
document.getElementById("view-loading").style.display = "none";
|
document.getElementById("view-loading").style.display = "none";
|
||||||
document.getElementById("view-unauth").style.display = "none";
|
document.getElementById("view-unauth").style.display = "none";
|
||||||
document.getElementById("view-auth").style.display = "none";
|
document.getElementById("view-auth").style.display = "none";
|
||||||
document.getElementById(`view-${name}`).style.display = "block";
|
document.getElementById(`view-${name}`).style.display = "block";
|
||||||
|
|
||||||
|
if (name === "auth") {
|
||||||
|
_refreshMeetingButtonState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Button state ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _showAddButton() {
|
||||||
|
document.getElementById("btn-generate").style.display = "block";
|
||||||
|
document.getElementById("btn-remove").style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showRemoveButton() {
|
||||||
|
document.getElementById("btn-generate").style.display = "none";
|
||||||
|
document.getElementById("btn-remove").style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setButtonLoading() {
|
||||||
|
const btn = document.getElementById("btn-generate");
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = t("meeting.generating");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setButtonIdle() {
|
||||||
|
const btn = document.getElementById("btn-generate");
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = t("meeting.add_meeting", { app_name: APP_NAME });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setRemoveLoading() {
|
||||||
|
const btn = document.getElementById("btn-remove");
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = t("meeting.removing");
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setRemoveIdle() {
|
||||||
|
const btn = document.getElementById("btn-remove");
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = t("meeting.remove_meeting", { app_name: APP_NAME });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _refreshMeetingButtonState() {
|
||||||
|
const item = Office.context.mailbox.item;
|
||||||
|
if (!item) return;
|
||||||
|
isMeetingAlreadyAdded(item).then((alreadyAdded) => {
|
||||||
|
if (alreadyAdded) {
|
||||||
|
_showRemoveButton();
|
||||||
|
} else {
|
||||||
|
_showAddButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
initSession()
|
initSession()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -47,22 +102,11 @@ function disconnect() {
|
|||||||
clearSession().finally(() => showView("unauth"));
|
clearSession().finally(() => showView("unauth"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function _setButtonLoading() {
|
// ── Meeting ──────────────────────────────────────────────────
|
||||||
const btn = document.getElementById("btn-generate");
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = "Génération...";
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setButtonIdle() {
|
|
||||||
const btn = document.getElementById("btn-generate");
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateMeetingLink() {
|
function generateMeetingLink() {
|
||||||
const session = loadSession();
|
const session = loadSession();
|
||||||
if (!session?.access_token) {
|
if (!session?.access_token) {
|
||||||
console.error("Session introuvable. Veuillez vous reconnecter.");
|
|
||||||
showView("unauth");
|
showView("unauth");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,36 +115,28 @@ function generateMeetingLink() {
|
|||||||
|
|
||||||
createRoom(session)
|
createRoom(session)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const { url, message } = buildMeetingMessage(data);
|
const isWeb = Office.context.diagnostics.platform === "OfficeOnline";
|
||||||
|
const { url, text } = buildMeetingMessage(data, isWeb);
|
||||||
const item = Office.context.mailbox.item;
|
const item = Office.context.mailbox.item;
|
||||||
|
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => {
|
||||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
reject(getResult.error);
|
reject(setResult.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
||||||
item.body.setAsync(
|
item.location.setAsync(url, () => resolve());
|
||||||
getResult.value + message,
|
return;
|
||||||
{ coercionType: Office.CoercionType.Html },
|
}
|
||||||
(setResult) => {
|
resolve();
|
||||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
|
||||||
reject(setResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── If calendar event, also set location ──────────────
|
|
||||||
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
|
||||||
item.location.setAsync(url, () => resolve());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
.then(() => {
|
||||||
|
_showRemoveButton();
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
})
|
})
|
||||||
@@ -109,7 +145,41 @@ function generateMeetingLink() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Office.onReady((info) => {
|
function removeMeetingLinkFromItem() {
|
||||||
|
const session = loadSession();
|
||||||
|
if (!session?.access_token) {
|
||||||
|
showView("unauth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setRemoveLoading();
|
||||||
|
|
||||||
|
const item = Office.context.mailbox.item;
|
||||||
|
|
||||||
|
removeMeetingLink(item)
|
||||||
|
.then(() => {
|
||||||
|
_showAddButton();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
_setRemoveIdle();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Office.onReady(async (info) => {
|
||||||
|
await initI18n();
|
||||||
|
translateUI();
|
||||||
|
|
||||||
|
if (FEEDBACK_FORM) {
|
||||||
|
const link = document.getElementById("feedback-link");
|
||||||
|
link.href = FEEDBACK_FORM;
|
||||||
|
link.style.display = "inline";
|
||||||
|
}
|
||||||
|
|
||||||
if (info.host === Office.HostType.Outlook) {
|
if (info.host === Office.HostType.Outlook) {
|
||||||
applyAppName();
|
applyAppName();
|
||||||
document.getElementById("sideload-msg").style.display = "none";
|
document.getElementById("sideload-msg").style.display = "none";
|
||||||
@@ -117,10 +187,11 @@ Office.onReady((info) => {
|
|||||||
document.getElementById("btn-connect").onclick = connect;
|
document.getElementById("btn-connect").onclick = connect;
|
||||||
document.getElementById("btn-disconnect").onclick = disconnect;
|
document.getElementById("btn-disconnect").onclick = disconnect;
|
||||||
document.getElementById("btn-generate").onclick = generateMeetingLink;
|
document.getElementById("btn-generate").onclick = generateMeetingLink;
|
||||||
|
document.getElementById("btn-remove").onclick = removeMeetingLinkFromItem;
|
||||||
|
|
||||||
const session = loadSession();
|
const session = loadSession();
|
||||||
if (session?.state === "authenticated" && session?.access_token) {
|
if (session?.state === "authenticated" && session?.access_token) {
|
||||||
showView("auth");
|
showView("auth"); // this already calls _refreshMeetingButtonState internally
|
||||||
} else {
|
} else {
|
||||||
showView("unauth");
|
showView("unauth");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
<div id="sideload-msg" data-i18n="app.sideload"></div>
|
||||||
<div
|
<div
|
||||||
class="spinner-container"
|
class="spinner-container"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-label="Chargement..."
|
data-i18n-aria="app.loading"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="spinner-svg"
|
class="spinner-svg"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const { applyAppName } = require("../common/helpers");
|
|||||||
const { URLS } = require("../common/urls");
|
const { URLS } = require("../common/urls");
|
||||||
const { save } = require("../common/transitToken");
|
const { save } = require("../common/transitToken");
|
||||||
const { DIALOG_SIGNALS } = require("../common/transitDialog");
|
const { DIALOG_SIGNALS } = require("../common/transitDialog");
|
||||||
|
const { initI18n, translateUI } = require("../common/i18n");
|
||||||
|
|
||||||
// Initiate the authentication flow, then return to the success page
|
// Initiate the authentication flow, then return to the success page
|
||||||
function getAuthenticateUrl() {
|
function getAuthenticateUrl() {
|
||||||
@@ -10,7 +11,11 @@ function getAuthenticateUrl() {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
Office.onReady(function (info) {
|
Office.onReady(async function (info) {
|
||||||
|
|
||||||
|
await initI18n();
|
||||||
|
translateUI();
|
||||||
|
|
||||||
if (info.host === Office.HostType.Outlook) {
|
if (info.host === Office.HostType.Outlook) {
|
||||||
applyAppName();
|
applyAppName();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.13.13-slim AS base
|
FROM python:3.14.6-slim AS base
|
||||||
|
|
||||||
# Install system dependencies required by LiveKit
|
# Install system dependencies required by LiveKit
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ from livekit.agents import (
|
|||||||
JobContext,
|
JobContext,
|
||||||
JobProcess,
|
JobProcess,
|
||||||
JobRequest,
|
JobRequest,
|
||||||
RoomInputOptions,
|
|
||||||
RoomIO,
|
RoomIO,
|
||||||
RoomOutputOptions,
|
|
||||||
WorkerPermissions,
|
WorkerPermissions,
|
||||||
cli,
|
cli,
|
||||||
utils,
|
utils,
|
||||||
)
|
)
|
||||||
|
from livekit.agents import (
|
||||||
|
room_io as lk_room_io,
|
||||||
|
)
|
||||||
from livekit.plugins import silero
|
from livekit.plugins import silero
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
from minio.error import S3Error
|
from minio.error import S3Error
|
||||||
@@ -302,13 +303,11 @@ class MetadataCollector:
|
|||||||
agent_session=session,
|
agent_session=session,
|
||||||
room=self.ctx.room,
|
room=self.ctx.room,
|
||||||
participant=participant,
|
participant=participant,
|
||||||
input_options=RoomInputOptions(
|
options=lk_room_io.RoomOptions(
|
||||||
audio_enabled=True,
|
audio_input=lk_room_io.AudioInputOptions(),
|
||||||
text_enabled=False,
|
text_input=False,
|
||||||
),
|
audio_output=False,
|
||||||
output_options=RoomOutputOptions(
|
text_output=False,
|
||||||
audio_enabled=False,
|
|
||||||
transcription_enabled=False,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -324,7 +323,6 @@ class MetadataCollector:
|
|||||||
async def _close_session(self, session: AgentSession) -> None:
|
async def _close_session(self, session: AgentSession) -> None:
|
||||||
"""Close and cleanup VAD monitoring session."""
|
"""Close and cleanup VAD monitoring session."""
|
||||||
try:
|
try:
|
||||||
await session.drain()
|
|
||||||
await session.aclose()
|
await session.aclose()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error closing session")
|
logger.exception("Error closing session")
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.18.0"
|
version = "1.22.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.4.5",
|
"livekit-agents==1.6.4",
|
||||||
"livekit-plugins-deepgram==1.4.5",
|
"livekit-plugins-deepgram==1.6.4",
|
||||||
"livekit-plugins-silero==1.4.5",
|
"livekit-plugins-silero==1.6.4",
|
||||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||||
"python-dotenv==1.2.2",
|
"python-dotenv==1.2.2",
|
||||||
"protobuf==6.33.5",
|
"protobuf>=6.33.5",
|
||||||
"minio==7.2.15"
|
"minio==7.2.20"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.6",
|
"ruff==0.15.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
Generated
+810
-737
File diff suppressed because it is too large
Load Diff
@@ -3,17 +3,55 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.contrib.auth import admin as auth_admin
|
from django.contrib.auth import admin as auth_admin
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils.html import format_html
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.recording.event import notification
|
from core.recording.event import notification
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
from .tasks.file import process_file_deletion
|
||||||
|
from .utils import generate_download_file_url
|
||||||
|
|
||||||
|
|
||||||
|
def hard_delete_file(file):
|
||||||
|
"""Hard delete a file, soft deleting it first when needed."""
|
||||||
|
if file.deleted_at is None:
|
||||||
|
file.soft_delete()
|
||||||
|
file.hard_delete()
|
||||||
|
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
|
||||||
|
|
||||||
|
|
||||||
|
class FileInlineFormSet(forms.BaseInlineFormSet):
|
||||||
|
"""Inline formset overriding delete behavior for files."""
|
||||||
|
|
||||||
|
def delete_existing(self, obj, commit=True):
|
||||||
|
"""Hard delete files instead of calling model.delete()."""
|
||||||
|
hard_delete_file(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class FileInline(admin.TabularInline):
|
||||||
|
"""Inline class for the File model."""
|
||||||
|
|
||||||
|
model = models.File
|
||||||
|
formset = FileInlineFormSet
|
||||||
|
fk_name = "creator"
|
||||||
|
extra = 0
|
||||||
|
fields = ("id", "title", "type", "upload_state", "created_at")
|
||||||
|
readonly_fields = ("id", "created_at", "upload_state", "type")
|
||||||
|
show_change_link = True
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Hide hard deleted files in the inline."""
|
||||||
|
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.User)
|
@admin.register(models.User)
|
||||||
class UserAdmin(auth_admin.UserAdmin):
|
class UserAdmin(auth_admin.UserAdmin):
|
||||||
"""Admin class for the User model"""
|
"""Admin class for the User model"""
|
||||||
|
|
||||||
|
inlines = (FileInline,)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
@@ -97,6 +135,136 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.File)
|
||||||
|
class FileAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin class for the File model."""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"type",
|
||||||
|
"creator",
|
||||||
|
"upload_state",
|
||||||
|
"deleted_at",
|
||||||
|
"hard_deleted_at",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"type",
|
||||||
|
"upload_state",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"deleted_at",
|
||||||
|
"hard_deleted_at",
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"filename",
|
||||||
|
"mimetype",
|
||||||
|
"description",
|
||||||
|
"creator__email",
|
||||||
|
"creator__admin_email",
|
||||||
|
"creator__full_name",
|
||||||
|
)
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
readonly_fields = (
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"deleted_at",
|
||||||
|
"hard_deleted_at",
|
||||||
|
"description",
|
||||||
|
"malware_detection_info",
|
||||||
|
"is_ready",
|
||||||
|
"preview_url",
|
||||||
|
"extension",
|
||||||
|
"key_base",
|
||||||
|
"file_key",
|
||||||
|
"upload_state",
|
||||||
|
"type",
|
||||||
|
"mimetype",
|
||||||
|
"size",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("creator",)
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"type",
|
||||||
|
"creator",
|
||||||
|
"filename",
|
||||||
|
"upload_state",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("Content"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"mimetype",
|
||||||
|
"size",
|
||||||
|
"description",
|
||||||
|
"malware_detection_info",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("Deletion"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"deleted_at",
|
||||||
|
"hard_deleted_at",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_("Derived info"),
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"is_ready",
|
||||||
|
"extension",
|
||||||
|
"key_base",
|
||||||
|
"file_key",
|
||||||
|
"preview_url",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(_("Timestamps"), {"fields": ("created_at", "updated_at")}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description=_("File preview"))
|
||||||
|
def preview_url(self, obj):
|
||||||
|
"""Return a clickable preview URL for the file."""
|
||||||
|
if not obj.is_ready:
|
||||||
|
return "-"
|
||||||
|
url = generate_download_file_url(obj, expires_in=60 * 60)
|
||||||
|
|
||||||
|
return format_html(
|
||||||
|
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Hide hard deleted files in admin listing and lookups."""
|
||||||
|
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
|
||||||
|
|
||||||
|
def delete_model(self, request, obj):
|
||||||
|
"""Hard delete instead of calling model.delete()."""
|
||||||
|
hard_delete_file(obj)
|
||||||
|
|
||||||
|
def delete_queryset(self, request, queryset):
|
||||||
|
"""Hard delete all selected files."""
|
||||||
|
for file in queryset:
|
||||||
|
hard_delete_file(file)
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ResourceAccessInline(admin.TabularInline):
|
class ResourceAccessInline(admin.TabularInline):
|
||||||
"""Admin class for the room user access model"""
|
"""Admin class for the room user access model"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Pluggable analytics.
|
||||||
|
|
||||||
|
Usage anywhere in the codebase:
|
||||||
|
|
||||||
|
from core import analytics
|
||||||
|
|
||||||
|
analytics.capture(request.user, "room_created", {"room_id": str(room.pk)})
|
||||||
|
|
||||||
|
The concrete backend is resolved lazily from Django settings, so swapping
|
||||||
|
PostHog for anything else is a configuration change, not a code change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from .base import AnalyticsBackend, NoOpAnalytics
|
||||||
|
from .events import AnalyticsEvent
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_analytics",
|
||||||
|
"identify",
|
||||||
|
"capture",
|
||||||
|
"AnalyticsBackend",
|
||||||
|
"AnalyticsEvent",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_analytics() -> AnalyticsBackend:
|
||||||
|
"""Instantiate the configured backend once per process."""
|
||||||
|
dotted_path = getattr(settings, "ANALYTICS_BACKEND", None)
|
||||||
|
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
|
||||||
|
|
||||||
|
if not dotted_path:
|
||||||
|
return NoOpAnalytics()
|
||||||
|
|
||||||
|
backend_class = import_string(dotted_path)
|
||||||
|
return backend_class(**options)
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience module-level shortcuts
|
||||||
|
|
||||||
|
analytics_instance = get_analytics()
|
||||||
|
|
||||||
|
|
||||||
|
def identify(user, properties: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Associate traits with an identified user."""
|
||||||
|
analytics_instance.identify(user, properties)
|
||||||
|
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
user, event: AnalyticsEvent, properties: dict[str, Any] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Record an event performed by an identified user."""
|
||||||
|
analytics_instance.capture(user, event, properties)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Analytics backend protocol and default no-op implementation."""
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from ..models import User
|
||||||
|
from .events import AnalyticsEvent
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsBackend(Protocol):
|
||||||
|
"""
|
||||||
|
Interface every analytics backend must implement.
|
||||||
|
|
||||||
|
Backends are instantiated once (singleton) with the kwargs declared in
|
||||||
|
settings.ANALYTICS_BACKEND_SETTINGS, e.g.:
|
||||||
|
|
||||||
|
ANALYTICS_BACKEND = "core.analytics.posthog.PostHogAnalytics"
|
||||||
|
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None: ...
|
||||||
|
|
||||||
|
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Associate traits (email, name, ...) with an identified user."""
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
event: AnalyticsEvent,
|
||||||
|
properties: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record an event performed by an identified user."""
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Flush pending events. Called on process exit."""
|
||||||
|
|
||||||
|
|
||||||
|
class NoOpAnalytics:
|
||||||
|
"""Default backend: silently discards everything."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
"""No-op: accepts and ignores any backend settings kwargs."""
|
||||||
|
|
||||||
|
def identify(self, user: User, properties=None) -> None:
|
||||||
|
"""No-op: discards identify calls."""
|
||||||
|
|
||||||
|
def capture(self, user, event, properties=None) -> None:
|
||||||
|
"""No-op: discards captured events."""
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""No-op: nothing to flush."""
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Catalog of all analytics events emitted by the backend."""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsEvent(StrEnum):
|
||||||
|
"""All trackable events. Values are the wire names sent to the provider."""
|
||||||
|
|
||||||
|
# Rooms
|
||||||
|
ROOM_CREATED = "room_created"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""PostHog implementation of the analytics backend protocol."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from posthog import Posthog
|
||||||
|
|
||||||
|
from ..models import User
|
||||||
|
from .events import AnalyticsEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PostHogAnalytics:
|
||||||
|
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
host: str = "https://eu.i.posthog.com",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# The SDK batches and sends in a background thread by default,
|
||||||
|
# so calls below never block the request/response cycle.
|
||||||
|
self._client = Posthog(
|
||||||
|
project_api_key=api_key,
|
||||||
|
host=host,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _distinct_id(user: User) -> str | None:
|
||||||
|
"""Return the PostHog distinct_id for a user, or None if anonymous."""
|
||||||
|
if user is None or not getattr(user, "is_authenticated", False):
|
||||||
|
return None
|
||||||
|
return str(user.pk)
|
||||||
|
|
||||||
|
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||||
|
"""Associate traits (email, name, ...) with an identified user."""
|
||||||
|
distinct_id = self._distinct_id(user)
|
||||||
|
if distinct_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._client.set(
|
||||||
|
distinct_id=distinct_id,
|
||||||
|
properties=properties or {},
|
||||||
|
)
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
logger.exception("PostHog identify failed")
|
||||||
|
|
||||||
|
def capture(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
event: AnalyticsEvent,
|
||||||
|
properties: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Record an event performed by an identified user."""
|
||||||
|
distinct_id = self._distinct_id(user)
|
||||||
|
if distinct_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._client.capture(
|
||||||
|
distinct_id=distinct_id,
|
||||||
|
event=str(event),
|
||||||
|
properties=properties or {},
|
||||||
|
)
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
logger.exception("PostHog capture failed for event %s", event)
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Flush pending events. Called on process exit."""
|
||||||
|
self._client.shutdown()
|
||||||
@@ -456,7 +456,7 @@ class ListFileSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def get_url(self, obj):
|
def get_url(self, obj):
|
||||||
"""Return the URL of the file."""
|
"""Return the URL of the file."""
|
||||||
if obj.is_pending_upload:
|
if not obj.is_ready:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}"
|
return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from rest_framework import (
|
|||||||
)
|
)
|
||||||
from rest_framework.settings import api_settings
|
from rest_framework.settings import api_settings
|
||||||
|
|
||||||
from core import enums, models, utils
|
from core import analytics, enums, models, utils
|
||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
from core.recording.enums import FileExtension
|
||||||
@@ -46,12 +46,15 @@ from core.recording.event.exceptions import (
|
|||||||
InvalidFileTypeError,
|
InvalidFileTypeError,
|
||||||
ParsingEventDataError,
|
ParsingEventDataError,
|
||||||
)
|
)
|
||||||
from core.recording.event.notification import notification_service
|
|
||||||
from core.recording.event.parsers import get_parser
|
from core.recording.event.parsers import get_parser
|
||||||
from core.recording.services.metadata_collector import (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
)
|
)
|
||||||
|
from core.recording.services.recording_events import (
|
||||||
|
RecordingEventsService,
|
||||||
|
RecordingNotSavableError,
|
||||||
|
)
|
||||||
from core.recording.worker.exceptions import (
|
from core.recording.worker.exceptions import (
|
||||||
RecordingStartError,
|
RecordingStartError,
|
||||||
RecordingStopError,
|
RecordingStopError,
|
||||||
@@ -305,6 +308,16 @@ class RoomViewSet(
|
|||||||
if callback_id := self.request.data.get("callback_id"):
|
if callback_id := self.request.data.get("callback_id"):
|
||||||
RoomCreation().persist_callback_state(callback_id, room)
|
RoomCreation().persist_callback_state(callback_id, room)
|
||||||
|
|
||||||
|
analytics.capture(
|
||||||
|
self.request.user,
|
||||||
|
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||||
|
{
|
||||||
|
"room_id": str(room.pk),
|
||||||
|
"access_level": room.access_level,
|
||||||
|
"from_callback": bool(self.request.data.get("callback_id")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""Persist the room update, then sync metadata to LiveKit."""
|
"""Persist the room update, then sync metadata to LiveKit."""
|
||||||
|
|
||||||
@@ -972,24 +985,15 @@ class RecordingViewSet(
|
|||||||
except models.Recording.DoesNotExist as e:
|
except models.Recording.DoesNotExist as e:
|
||||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||||
|
|
||||||
if not recording.is_savable():
|
# Save recording
|
||||||
|
recording_events_service = RecordingEventsService()
|
||||||
|
try:
|
||||||
|
recording_events_service.handle_complete(recording)
|
||||||
|
except RecordingNotSavableError:
|
||||||
raise drf_exceptions.PermissionDenied(
|
raise drf_exceptions.PermissionDenied(
|
||||||
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
||||||
" in an error state or has already been saved."
|
" in an error state or has already been saved."
|
||||||
)
|
) from None
|
||||||
|
|
||||||
# Attempt to notify external services about the recording
|
|
||||||
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
|
||||||
notification_succeeded = notification_service.notify_external_services(
|
|
||||||
recording
|
|
||||||
)
|
|
||||||
|
|
||||||
recording.status = (
|
|
||||||
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
|
||||||
if notification_succeeded
|
|
||||||
else models.RecordingStatusChoices.SAVED
|
|
||||||
)
|
|
||||||
recording.save()
|
|
||||||
|
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"message": "Event processed."},
|
{"message": "Event processed."},
|
||||||
@@ -1187,7 +1191,10 @@ class FileViewSet(
|
|||||||
serializer.save(creator=self.request.user)
|
serializer.save(creator=self.request.user)
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
"""Override to implement a soft delete instead of dumping the record in database."""
|
"""Override to implement a soft delete instead of dumping the record in database.
|
||||||
|
|
||||||
|
Files are actually purged by commands that should run periodically.
|
||||||
|
"""
|
||||||
instance.soft_delete()
|
instance.soft_delete()
|
||||||
|
|
||||||
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
||||||
@@ -1326,7 +1333,7 @@ class FileViewSet(
|
|||||||
"""Delete a file completely."""
|
"""Delete a file completely."""
|
||||||
file.soft_delete()
|
file.soft_delete()
|
||||||
file.hard_delete()
|
file.hard_delete()
|
||||||
process_file_deletion.delay(file.id)
|
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
|
||||||
|
|
||||||
def _authorize_subrequest(self, request, pattern):
|
def _authorize_subrequest(self, request, pattern):
|
||||||
"""
|
"""
|
||||||
@@ -1417,7 +1424,7 @@ class FileViewSet(
|
|||||||
request, MEDIA_STORAGE_URL_PATTERN
|
request, MEDIA_STORAGE_URL_PATTERN
|
||||||
)
|
)
|
||||||
|
|
||||||
if file.is_pending_upload:
|
if not file.is_ready:
|
||||||
logger.warning("File '%s' is not ready", file.id)
|
logger.warning("File '%s' is not ready", file.id)
|
||||||
raise drf_exceptions.PermissionDenied()
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from lasuite.oidc_login.backends import (
|
from lasuite.oidc_login.backends import (
|
||||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||||
)
|
)
|
||||||
|
from rest_framework.authentication import SessionAuthentication
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.services.marketing import (
|
from core.services.marketing import (
|
||||||
@@ -96,3 +97,17 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
|||||||
"Multiple user accounts share a common email."
|
"Multiple user accounts share a common email."
|
||||||
) from e
|
) from e
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionAuthenticationWith401(SessionAuthentication):
|
||||||
|
"""
|
||||||
|
Identical to DRF's SessionAuthentication, but returns a WWW-Authenticate
|
||||||
|
header so unauthenticated requests get a 401 instead of a 403.
|
||||||
|
|
||||||
|
The scheme is deliberately NOT 'Basic' — that would trigger the browser's
|
||||||
|
native login popup. 'Session' is ignored by the browser's auth UI but is
|
||||||
|
still truthy, so DRF keeps the status at 401.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
return "Session"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from rest_framework import (
|
|||||||
status as drf_status,
|
status as drf_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
from core import api, models
|
from core import analytics, api, models
|
||||||
from core.api.feature_flag import FeatureFlag
|
from core.api.feature_flag import FeatureFlag
|
||||||
from core.services.jwt_token import JwtTokenService
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
|
||||||
@@ -194,10 +194,26 @@ class RoomViewSet(
|
|||||||
role=models.RoleChoices.OWNER,
|
role=models.RoleChoices.OWNER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
auth_method = type(self.request.successful_authenticator).__name__
|
||||||
|
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||||
|
|
||||||
# Log for auditing
|
# Log for auditing
|
||||||
logger.info(
|
logger.info(
|
||||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
|
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
|
||||||
room.id,
|
room.id,
|
||||||
self.request.user.id,
|
self.request.user.id,
|
||||||
getattr(self.request.auth, "client_id", "unknown"),
|
client_id,
|
||||||
|
auth_method,
|
||||||
|
)
|
||||||
|
|
||||||
|
analytics.capture(
|
||||||
|
self.request.user,
|
||||||
|
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||||
|
{
|
||||||
|
"room_id": str(room.pk),
|
||||||
|
"access_level": room.access_level,
|
||||||
|
"client_id": client_id,
|
||||||
|
"external_api": True,
|
||||||
|
"auth_method": auth_method,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Clean stale pending files that were never fully uploaded."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.models import File, FileUploadStateChoices
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Remove pending files older than a given threshold."""
|
||||||
|
|
||||||
|
help = "Delete pending files that have been stuck for too long"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--hours",
|
||||||
|
type=int,
|
||||||
|
default=24,
|
||||||
|
help="Age threshold in hours (default: 24)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
hours = options["hours"]
|
||||||
|
if hours < 0:
|
||||||
|
raise CommandError("Hours must be greater than 0")
|
||||||
|
|
||||||
|
threshold = timezone.now() - timedelta(hours=hours)
|
||||||
|
|
||||||
|
files = File.objects.filter(
|
||||||
|
upload_state=FileUploadStateChoices.PENDING,
|
||||||
|
created_at__lt=threshold,
|
||||||
|
hard_deleted_at__isnull=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for file in files.iterator():
|
||||||
|
# This check shouldn't happen, but just in case we do it to avoid an error
|
||||||
|
if not file.deleted_at:
|
||||||
|
file.soft_delete()
|
||||||
|
file.hard_delete()
|
||||||
|
process_file_deletion(file.id)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
self.stdout.write(f"Cleaned {count} stale pending file(s).")
|
||||||
@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
from django.db.models.functions import Lower
|
||||||
|
|
||||||
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
|
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
|
||||||
|
|
||||||
@@ -20,11 +21,14 @@ ROLE_PRIORITY = {
|
|||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
Merge duplicate users sharing the same email into the most recently created one.
|
Merge duplicate users sharing the same email (case-insensitive) into the
|
||||||
|
most recently created one.
|
||||||
|
|
||||||
The KEPT user is the most recently created. All room memberships, recording
|
Emails are compared case-insensitively, so 'John@Example.com' and
|
||||||
accesses and files are transferred to it. When a conflict exists, the
|
'john@example.com' are treated as duplicates. The KEPT user is the most
|
||||||
higher-privilege role wins. Stale users are then deleted.
|
recently created. All room memberships, recording accesses and files are
|
||||||
|
transferred to it. When a conflict exists, the higher-privilege role wins.
|
||||||
|
Stale users are then deleted.
|
||||||
Each email group is processed inside a single database transaction.
|
Each email group is processed inside a single database transaction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -56,13 +60,16 @@ class Command(BaseCommand):
|
|||||||
users_qs = users_qs.filter(email__icontains=email_filter)
|
users_qs = users_qs.filter(email__icontains=email_filter)
|
||||||
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
|
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
|
||||||
|
|
||||||
|
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
|
||||||
|
# are detected as duplicates of each other.
|
||||||
duplicate_emails = (
|
duplicate_emails = (
|
||||||
users_qs.exclude(email__isnull=True)
|
users_qs.exclude(email__isnull=True)
|
||||||
.exclude(email="")
|
.exclude(email="")
|
||||||
.values("email")
|
.annotate(email_lower=Lower("email"))
|
||||||
|
.values("email_lower")
|
||||||
.annotate(cnt=Count("id"))
|
.annotate(cnt=Count("id"))
|
||||||
.filter(cnt__gt=1)
|
.filter(cnt__gt=1)
|
||||||
.values_list("email", flat=True)
|
.values_list("email_lower", flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not duplicate_emails:
|
if not duplicate_emails:
|
||||||
@@ -78,9 +85,12 @@ class Command(BaseCommand):
|
|||||||
failed_emails = []
|
failed_emails = []
|
||||||
|
|
||||||
for email in duplicate_emails:
|
for email in duplicate_emails:
|
||||||
|
# Case-insensitive lookup to fetch every casing variant of the email.
|
||||||
# Secondary sort by id ensures a stable, deterministic order when
|
# Secondary sort by id ensures a stable, deterministic order when
|
||||||
# created_at timestamps are equal (common in tests and bulk imports).
|
# created_at timestamps are equal (common in tests and bulk imports).
|
||||||
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
|
users = list(
|
||||||
|
User.objects.filter(email__iexact=email).order_by("created_at", "id")
|
||||||
|
)
|
||||||
kept_user = users[-1]
|
kept_user = users[-1]
|
||||||
stale_users = users[:-1]
|
stale_users = users[:-1]
|
||||||
|
|
||||||
@@ -120,6 +130,10 @@ class Command(BaseCommand):
|
|||||||
failed_emails.append(email)
|
failed_emails.append(email)
|
||||||
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
|
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
|
||||||
|
|
||||||
|
if not kept_user.email.islower():
|
||||||
|
kept_user.email = kept_user.email.lower()
|
||||||
|
kept_user.save(update_fields=["email"])
|
||||||
|
|
||||||
if failed_emails:
|
if failed_emails:
|
||||||
raise CommandError(
|
raise CommandError(
|
||||||
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
|
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Purge deleted files."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.models import File
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""
|
||||||
|
Purge deleted files (object storage and database object):
|
||||||
|
- files marked as hard deleted in database
|
||||||
|
- files marked as soft deleted and for which the trashbin retention period has expired
|
||||||
|
"""
|
||||||
|
|
||||||
|
help = "Purge deleted files"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
"""Browse purgeable files and queue them through the file deletion task."""
|
||||||
|
|
||||||
|
is_hard_deleted = Q(hard_deleted_at__isnull=False)
|
||||||
|
is_purgeable = Q(
|
||||||
|
deleted_at__lte=timezone.now()
|
||||||
|
- timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for file in File.objects.filter(is_hard_deleted | is_purgeable).iterator():
|
||||||
|
if file.hard_deleted_at is None:
|
||||||
|
file.hard_delete()
|
||||||
|
|
||||||
|
process_file_deletion.delay(file.id)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
self.stdout.write(f"Purged {count} deleted file(s).")
|
||||||
@@ -925,9 +925,9 @@ class File(BaseModel):
|
|||||||
return super().delete(using, keep_parents)
|
return super().delete(using, keep_parents)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_pending_upload(self):
|
def is_ready(self):
|
||||||
"""Return whether the file is in a pending upload state"""
|
"""Return whether the file is in a ready upload state"""
|
||||||
return self.upload_state == FileUploadStateChoices.PENDING
|
return self.upload_state == FileUploadStateChoices.READY
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extension(self):
|
def extension(self):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import re
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any, Dict, Optional, Protocol
|
from typing import Any, Dict, Optional, Protocol
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
@@ -58,7 +59,7 @@ class EventParser(Protocol):
|
|||||||
def parse(self, data: Dict) -> StorageEvent:
|
def parse(self, data: Dict) -> StorageEvent:
|
||||||
"""Extract storage event data from raw dictionary input."""
|
"""Extract storage event data from raw dictionary input."""
|
||||||
|
|
||||||
def validate(self, data: StorageEvent) -> None:
|
def validate(self, data: StorageEvent) -> str:
|
||||||
"""Verify storage event data meets all requirements."""
|
"""Verify storage event data meets all requirements."""
|
||||||
|
|
||||||
def get_recording_id(self, data: Dict) -> str:
|
def get_recording_id(self, data: Dict) -> str:
|
||||||
@@ -164,6 +165,9 @@ class S3Parser(BaseS3Parser):
|
|||||||
if not filepath:
|
if not filepath:
|
||||||
raise ParsingEventDataError("Missing object key name")
|
raise ParsingEventDataError("Missing object key name")
|
||||||
filetype, _ = mimetypes.guess_type(filepath)
|
filetype, _ = mimetypes.guess_type(filepath)
|
||||||
|
# Normalize raw S3-compatible object keys without re-encoding
|
||||||
|
# already encoded AWS S3 notification keys.
|
||||||
|
filepath = quote(filepath, safe="%+")
|
||||||
return StorageEvent(
|
return StorageEvent(
|
||||||
filepath=filepath,
|
filepath=filepath,
|
||||||
filetype=filetype,
|
filetype=filetype,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from livekit import api
|
|||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.models import Recording
|
from core.models import Recording
|
||||||
|
from core.recording.event.notification import notification_service
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ class RecordingEventsError(Exception):
|
|||||||
"""Recording event handling fails."""
|
"""Recording event handling fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingNotSavableError(Exception):
|
||||||
|
"""Recording cannot be saved because it is either in an error state or has already been saved"""
|
||||||
|
|
||||||
|
|
||||||
class RecordingEventsService:
|
class RecordingEventsService:
|
||||||
"""Handles recording-related LiveKit webhook events."""
|
"""Handles recording-related LiveKit webhook events."""
|
||||||
|
|
||||||
@@ -73,3 +78,23 @@ class RecordingEventsService:
|
|||||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||||
f"recording limit reached (recording_id={recording.id})"
|
f"recording limit reached (recording_id={recording.id})"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def handle_complete(recording: Recording):
|
||||||
|
"""Notify external services and save recording."""
|
||||||
|
|
||||||
|
if not recording.is_savable():
|
||||||
|
raise RecordingNotSavableError
|
||||||
|
|
||||||
|
# Attempt to notify external services about the recording
|
||||||
|
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
||||||
|
notification_succeeded = notification_service.notify_external_services(
|
||||||
|
recording
|
||||||
|
)
|
||||||
|
|
||||||
|
recording.status = (
|
||||||
|
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||||
|
if notification_succeeded
|
||||||
|
else models.RecordingStatusChoices.SAVED
|
||||||
|
)
|
||||||
|
recording.save()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from core.recording.services.metadata_collector import (
|
|||||||
from core.recording.services.recording_events import (
|
from core.recording.services.recording_events import (
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
RecordingEventsService,
|
RecordingEventsService,
|
||||||
|
RecordingNotSavableError,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
@@ -88,6 +89,13 @@ class LiveKitEventsService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize with required services."""
|
"""Initialize with required services."""
|
||||||
|
|
||||||
|
self._webhook_handlers = {
|
||||||
|
"egress_updated": self._handle_egress_updated,
|
||||||
|
"egress_ended": self._handle_egress_ended,
|
||||||
|
"room_started": self._handle_room_started,
|
||||||
|
"room_finished": self._handle_room_finished,
|
||||||
|
}
|
||||||
|
|
||||||
token_verifier = api.TokenVerifier(
|
token_verifier = api.TokenVerifier(
|
||||||
settings.LIVEKIT_CONFIGURATION["api_key"],
|
settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
@@ -135,14 +143,11 @@ class LiveKitEventsService:
|
|||||||
f"Unknown webhook type: {data.event}"
|
f"Unknown webhook type: {data.event}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
handler_name = f"_handle_{webhook_type.value}"
|
# Handle according to received webhook type
|
||||||
handler = getattr(self, handler_name, None)
|
handler = self._webhook_handlers.get(webhook_type.value)
|
||||||
|
|
||||||
if not handler or not callable(handler):
|
if handler is not None:
|
||||||
return
|
handler(data)
|
||||||
|
|
||||||
# pylint: disable=not-callable
|
|
||||||
handler(data)
|
|
||||||
|
|
||||||
def _handle_egress_updated(self, data):
|
def _handle_egress_updated(self, data):
|
||||||
"""Handle 'egress_updated' event."""
|
"""Handle 'egress_updated' event."""
|
||||||
@@ -195,6 +200,24 @@ class LiveKitEventsService:
|
|||||||
f"Failed to process limit reached event for recording {recording}"
|
f"Failed to process limit reached event for recording {recording}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
# Fallback for completion when no MinIO/S3 webhooks are configured
|
||||||
|
if (
|
||||||
|
not settings.RECORDING_STORAGE_EVENT_ENABLE
|
||||||
|
) and data.egress_info.status in [
|
||||||
|
api.EgressStatus.EGRESS_COMPLETE,
|
||||||
|
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
self.recording_events.handle_complete(recording)
|
||||||
|
except RecordingNotSavableError:
|
||||||
|
logger.warning(
|
||||||
|
"Recording %s is not savable on egress complete "
|
||||||
|
"(already saved or in an error state); ignoring.",
|
||||||
|
recording.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||||
|
|
||||||
def _handle_room_started(self, data):
|
def _handle_room_started(self, data):
|
||||||
"""Handle 'room_started' event."""
|
"""Handle 'room_started' event."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for PostHogAnalytics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.analytics.events import AnalyticsEvent
|
||||||
|
from core.analytics.posthog import PostHogAnalytics
|
||||||
|
from core.factories import UserFactory
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# __init__
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_init_constructs_posthog_client_with_api_key_and_host(mock_posthog_cls):
|
||||||
|
"""Should forward api_key and host to the Posthog SDK constructor."""
|
||||||
|
PostHogAnalytics(api_key="my-key", host="https://custom.i.posthog.com")
|
||||||
|
|
||||||
|
mock_posthog_cls.assert_called_once_with(
|
||||||
|
project_api_key="my-key",
|
||||||
|
host="https://custom.i.posthog.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_init_defaults_to_eu_host(mock_posthog_cls):
|
||||||
|
"""Should default host to the EU PostHog cloud when not specified."""
|
||||||
|
PostHogAnalytics(api_key="my-key")
|
||||||
|
|
||||||
|
_, kwargs = mock_posthog_cls.call_args
|
||||||
|
assert kwargs["host"] == "https://eu.i.posthog.com"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_init_forwards_extra_kwargs_to_client(mock_posthog_cls):
|
||||||
|
"""Should pass through arbitrary extra kwargs (e.g. debug, disabled) to the SDK."""
|
||||||
|
PostHogAnalytics(api_key="my-key", debug=True, disabled=False)
|
||||||
|
|
||||||
|
_, kwargs = mock_posthog_cls.call_args
|
||||||
|
assert kwargs["debug"] is True
|
||||||
|
assert kwargs["disabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# _distinct_id
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_distinct_id_returns_none_for_none_user(mock_posthog_cls):
|
||||||
|
"""Should return None when user is None."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
assert backend._distinct_id(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_distinct_id_returns_none_for_anonymous_user(mock_posthog_cls):
|
||||||
|
"""Should return None when user.is_authenticated is falsy."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
assert backend._distinct_id(AnonymousUser()) is None
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_distinct_id_returns_none_when_attribute_missing(mock_posthog_cls):
|
||||||
|
"""Should return None when the user object has no is_authenticated attribute at all."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
assert backend._distinct_id(object()) is None
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_distinct_id_returns_stringified_pk_for_authenticated_user(mock_posthog_cls):
|
||||||
|
"""Should return str(user.pk) for an authenticated user."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
assert backend._distinct_id(user) == str(user.pk)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# identify
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_identify_noop_for_anonymous_user(mock_posthog_cls):
|
||||||
|
"""Should not call the SDK when the user is anonymous."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
backend.identify(AnonymousUser(), {"email": "a@example.com"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_identify_noop_for_none_user(mock_posthog_cls):
|
||||||
|
"""Should not call the SDK when user is None."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
backend.identify(None, {"email": "a@example.com"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.set.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_identify_sends_set_properties_for_authenticated_user(mock_posthog_cls):
|
||||||
|
"""Should call capture with event=$identify and properties wrapped in $set."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
backend.identify(user, {"email": "a@example.com", "name": "A"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.set.assert_called_once_with(
|
||||||
|
distinct_id=str(user.pk),
|
||||||
|
properties={"email": "a@example.com", "name": "A"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_identify_defaults_properties_to_empty_dict(mock_posthog_cls):
|
||||||
|
"""Should send an empty $set payload when properties is None."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
backend.identify(user, None)
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.set.assert_called_once_with(
|
||||||
|
distinct_id=str(user.pk),
|
||||||
|
properties={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_identify_swallows_sdk_exceptions(mock_posthog_cls):
|
||||||
|
"""Should log and not raise when the SDK call fails."""
|
||||||
|
mock_posthog_cls.return_value.set.side_effect = RuntimeError("network down")
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
# Must not propagate.
|
||||||
|
backend.identify(user, {"email": "a@example.com"})
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# capture
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_noop_for_anonymous_user(mock_posthog_cls):
|
||||||
|
"""Should not call the SDK when the user is anonymous."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
backend.capture(AnonymousUser(), AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.capture.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_noop_for_none_user(mock_posthog_cls):
|
||||||
|
"""Should not call the SDK when user is None."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
backend.capture(None, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.capture.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_sends_event_and_properties_for_authenticated_user(mock_posthog_cls):
|
||||||
|
"""Should call capture with the distinct_id, event name, and properties."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "room-1"})
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.capture.assert_called_once_with(
|
||||||
|
distinct_id=str(user.pk),
|
||||||
|
event="room_created",
|
||||||
|
properties={"room_id": "room-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_serializes_event_enum_to_plain_string(mock_posthog_cls):
|
||||||
|
"""Should send the wire string, not the AnalyticsEvent enum member, to the SDK."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
|
||||||
|
|
||||||
|
_, kwargs = mock_posthog_cls.return_value.capture.call_args
|
||||||
|
assert kwargs["event"] == "room_created"
|
||||||
|
assert isinstance(
|
||||||
|
kwargs["event"], str
|
||||||
|
) # not AnalyticsEvent, not StrEnum subclass leaking through
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_defaults_properties_to_empty_dict(mock_posthog_cls):
|
||||||
|
"""Should send an empty properties dict when properties is None."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
backend.capture(user, AnalyticsEvent.ROOM_CREATED, None)
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.capture.assert_called_once_with(
|
||||||
|
distinct_id=str(user.pk),
|
||||||
|
event="room_created",
|
||||||
|
properties={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_swallows_sdk_exceptions(mock_posthog_cls):
|
||||||
|
"""Should log and not raise when the SDK call fails."""
|
||||||
|
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
# Must not propagate.
|
||||||
|
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, caplog):
|
||||||
|
"""Should log which event failed, to aid debugging without crashing the caller."""
|
||||||
|
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
with caplog.at_level("ERROR"):
|
||||||
|
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
|
||||||
|
|
||||||
|
assert any("PostHog capture failed" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# shutdown
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_shutdown_flushes_the_client(mock_posthog_cls):
|
||||||
|
"""Should delegate to the SDK's shutdown to flush pending events."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
|
||||||
|
backend.shutdown()
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.shutdown.assert_called_once()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Tests for the clean_pending_files management command."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_no_stale_files():
|
||||||
|
"""Nothing happens when there are no stale pending files."""
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_recent_pending_not_deleted():
|
||||||
|
"""Recent pending files (within threshold) should not be deleted."""
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.deleted_at is None
|
||||||
|
assert default_storage.exists(file.file_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_old_pending_deleted():
|
||||||
|
"""Pending files older than the threshold should be deleted."""
|
||||||
|
old_date = timezone.now() - timedelta(hours=49)
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
assert default_storage.exists(file.file_key)
|
||||||
|
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||||
|
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
assert not models.File.objects.filter(pk=file.pk).exists()
|
||||||
|
assert not default_storage.exists(file.file_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_old_non_pending_not_deleted():
|
||||||
|
"""Old files that are not pending should not be deleted."""
|
||||||
|
old_date = timezone.now() - timedelta(hours=49)
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.READY,
|
||||||
|
)
|
||||||
|
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||||
|
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.deleted_at is None
|
||||||
|
assert file.hard_deleted_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_pending_files_custom_hours():
|
||||||
|
"""The --hours argument controls the age threshold."""
|
||||||
|
old_date = timezone.now() - timedelta(hours=10)
|
||||||
|
file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||||
|
|
||||||
|
# Default 24h threshold -> file not deleted
|
||||||
|
call_command("clean_pending_files")
|
||||||
|
|
||||||
|
file.refresh_from_db()
|
||||||
|
assert file.deleted_at is None
|
||||||
|
assert default_storage.exists(file.file_key)
|
||||||
|
|
||||||
|
# 8h threshold -> file deleted
|
||||||
|
call_command("clean_pending_files", "--hours=8")
|
||||||
|
|
||||||
|
assert not models.File.objects.filter(pk=file.pk).exists()
|
||||||
|
assert not default_storage.exists(file.file_key)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for the purge_deleted_files management command."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from io import StringIO
|
||||||
|
from random import randint
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_purge_deleted_files_no_deleted_files(django_assert_num_queries):
|
||||||
|
"""Nothing happens when there are no purgeable files."""
|
||||||
|
with django_assert_num_queries(1):
|
||||||
|
call_command("purge_deleted_files")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
def test_purge_deleted_files_success(settings):
|
||||||
|
"""
|
||||||
|
Queue deletion for:
|
||||||
|
- hard-deleted files
|
||||||
|
- soft-deleted files past retention period + grace period.
|
||||||
|
"""
|
||||||
|
out = StringIO()
|
||||||
|
|
||||||
|
settings.FILE_PURGE_GRACE_DAYS = grace = randint(1, 20)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
purge_now = now - timedelta(days=grace)
|
||||||
|
|
||||||
|
not_deleted_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("django.utils.timezone.now", return_value=now):
|
||||||
|
not_purgeable_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
not_purgeable_file.soft_delete()
|
||||||
|
|
||||||
|
with patch("django.utils.timezone.now", return_value=purge_now):
|
||||||
|
purgeable_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
purgeable_file.soft_delete()
|
||||||
|
|
||||||
|
hard_deleted_file = factories.FileFactory(
|
||||||
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
|
upload_bytes=b"hello",
|
||||||
|
)
|
||||||
|
hard_deleted_file.soft_delete()
|
||||||
|
hard_deleted_file.hard_delete()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"core.management.commands.purge_deleted_files.process_file_deletion.delay",
|
||||||
|
side_effect=process_file_deletion,
|
||||||
|
) as mock_delay:
|
||||||
|
call_command("purge_deleted_files", stdout=out)
|
||||||
|
|
||||||
|
assert "Purged 2 deleted file(s)." in out.getvalue()
|
||||||
|
assert mock_delay.call_count == 2
|
||||||
|
called_ids = {call.args[0] for call in mock_delay.call_args_list}
|
||||||
|
assert called_ids == {purgeable_file.id, hard_deleted_file.id}
|
||||||
|
|
||||||
|
assert models.File.objects.filter(id=not_deleted_file.id).exists()
|
||||||
|
assert models.File.objects.filter(id=not_purgeable_file.id).exists()
|
||||||
|
assert not models.File.objects.filter(id=purgeable_file.id).exists()
|
||||||
|
assert not models.File.objects.filter(id=hard_deleted_file.id).exists()
|
||||||
|
|
||||||
|
assert default_storage.exists(not_deleted_file.file_key)
|
||||||
|
assert default_storage.exists(not_purgeable_file.file_key)
|
||||||
|
assert not default_storage.exists(purgeable_file.file_key)
|
||||||
|
assert not default_storage.exists(hard_deleted_file.file_key)
|
||||||
@@ -86,7 +86,11 @@ def test_api_files_media_get_own():
|
|||||||
assert response.content.decode("utf-8") == "my prose"
|
assert response.content.decode("utf-8") == "my prose"
|
||||||
|
|
||||||
|
|
||||||
def test_api_files_media_auth_file_pending():
|
@pytest.mark.parametrize(
|
||||||
|
"rejecting_status",
|
||||||
|
[models.FileUploadStateChoices.PENDING, models.FileUploadStateChoices.ANALYZING],
|
||||||
|
)
|
||||||
|
def test_api_files_media_auth_rejects(rejecting_status):
|
||||||
"""
|
"""
|
||||||
Users who have a specific access to an file, whatever the role, should not be able to
|
Users who have a specific access to an file, whatever the role, should not be able to
|
||||||
retrieve related attachments if the file is not ready.
|
retrieve related attachments if the file is not ready.
|
||||||
@@ -97,7 +101,7 @@ def test_api_files_media_auth_file_pending():
|
|||||||
|
|
||||||
file = factories.FileFactory(
|
file = factories.FileFactory(
|
||||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||||
upload_state=models.FileUploadStateChoices.PENDING,
|
upload_state=rejecting_status,
|
||||||
creator=user,
|
creator=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,26 @@ def test_merge_keeps_most_recently_created_user():
|
|||||||
assert User.objects.filter(id=user2.id).exists()
|
assert User.objects.filter(id=user2.id).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_user_case_insensitive():
|
||||||
|
"""Emails differing only by case should be treated as duplicates and merged,
|
||||||
|
keeping the most recently created user."""
|
||||||
|
user1 = UserFactory(email="Dup@example.com")
|
||||||
|
user2 = UserFactory(email="dup@example.com")
|
||||||
|
call_command("merge_duplicate_users")
|
||||||
|
|
||||||
|
assert not User.objects.filter(id=user1.id).exists()
|
||||||
|
assert User.objects.filter(id=user2.id).exists()
|
||||||
|
|
||||||
|
user3 = UserFactory(email="joe@example.com")
|
||||||
|
user4 = UserFactory(email="Joe@example.com")
|
||||||
|
call_command("merge_duplicate_users")
|
||||||
|
assert not User.objects.filter(id=user3.id).exists()
|
||||||
|
assert User.objects.filter(id=user4.id).exists()
|
||||||
|
|
||||||
|
user4.refresh_from_db()
|
||||||
|
assert user4.email.islower()
|
||||||
|
|
||||||
|
|
||||||
def test_merge_deletes_all_stale_users():
|
def test_merge_deletes_all_stale_users():
|
||||||
"""Command should delete all stale users and keep only the most recently created one."""
|
"""Command should delete all stale users and keep only the most recently created one."""
|
||||||
email = "many@example.com"
|
email = "many@example.com"
|
||||||
@@ -457,4 +477,4 @@ def test_merge_email_filter_is_case_insensitive():
|
|||||||
UserFactory(email="user1@Example.com")
|
UserFactory(email="user1@Example.com")
|
||||||
UserFactory(email="user1@Example.com")
|
UserFactory(email="user1@Example.com")
|
||||||
call_command("merge_duplicate_users", email_filter="@example.com")
|
call_command("merge_duplicate_users", email_filter="@example.com")
|
||||||
assert User.objects.filter(email="user1@Example.com").count() == 1
|
assert User.objects.filter(email="user1@example.com").count() == 1
|
||||||
|
|||||||
@@ -360,6 +360,75 @@ def test_s3_parse_unrecognized_extension(s3_parser):
|
|||||||
s3_parser.parse(event_with_unknown_ext)
|
s3_parser.parse(event_with_unknown_ext)
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parser_keeps_encoded_filepath_compatible(settings):
|
||||||
|
"""Test S3 parser keeps already encoded object keys compatible."""
|
||||||
|
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||||
|
|
||||||
|
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||||
|
parser = S3Parser(bucket_name="recordings-bucket")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "recordings-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": f"recordings%2F{recording_id}.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert parser.get_recording_id(data) == recording_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parser_accepts_unencoded_filepath(settings):
|
||||||
|
"""Test S3 parser accepts raw object keys with slash separators."""
|
||||||
|
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||||
|
|
||||||
|
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||||
|
parser = S3Parser(bucket_name="recordings-bucket")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "recordings-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": f"recordings/{recording_id}.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert parser.get_recording_id(data) == recording_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_s3_parser_preserves_plus_signs_in_encoded_filepath(settings):
|
||||||
|
"""Test S3 parser preserves plus signs in already encoded object keys."""
|
||||||
|
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||||
|
|
||||||
|
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||||
|
parser = S3Parser(bucket_name="recordings-bucket")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "recordings-bucket"},
|
||||||
|
"object": {
|
||||||
|
"key": f"folder+name%2Frecordings%2F{recording_id}.mp4",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert parser.get_recording_id(data) == recording_id
|
||||||
|
|
||||||
|
|
||||||
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
|
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
|
||||||
"""Test successful extraction of recording ID from S3 event."""
|
"""Test successful extraction of recording ID from S3 event."""
|
||||||
recording_id = s3_parser.get_recording_id(valid_s3_event)
|
recording_id = s3_parser.get_recording_id(valid_s3_event)
|
||||||
|
|||||||
@@ -224,3 +224,44 @@ def test_save_recording_success(recording_settings, mock_get_parser, client, sta
|
|||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == RecordingStatusChoices.SAVED
|
assert recording.status == RecordingStatusChoices.SAVED
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("notification_succeeded", [True, False])
|
||||||
|
def test_save_recording_notifies_external_services(
|
||||||
|
mock_notify_external_services,
|
||||||
|
recording_settings,
|
||||||
|
mock_get_parser,
|
||||||
|
client,
|
||||||
|
notification_succeeded,
|
||||||
|
):
|
||||||
|
"""External services should be notified when a recording is saved."""
|
||||||
|
|
||||||
|
recording = RecordingFactory(status="active")
|
||||||
|
|
||||||
|
mock_parser = mock.Mock()
|
||||||
|
mock_parser.get_recording_id.return_value = recording.id
|
||||||
|
mock_get_parser.return_value = mock_parser
|
||||||
|
|
||||||
|
mock_notify_external_services.return_value = notification_succeeded
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1.0/recordings/storage-hook/",
|
||||||
|
{"recording_data": "valid-data"},
|
||||||
|
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"message": "Event processed."}
|
||||||
|
|
||||||
|
mock_notify_external_services.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == (
|
||||||
|
RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||||
|
if notification_succeeded
|
||||||
|
else RecordingStatusChoices.SAVED
|
||||||
|
)
|
||||||
|
|||||||
@@ -776,7 +776,7 @@ def test_update_participant_invalid_permission():
|
|||||||
"loc": ["invalid-attributes"],
|
"loc": ["invalid-attributes"],
|
||||||
"msg": "Extra inputs are not permitted",
|
"msg": "Extra inputs are not permitted",
|
||||||
"input": "True",
|
"input": "True",
|
||||||
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
|
"url": "https://errors.pydantic.dev/2.13/v/extra_forbidden",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ def test_handle_egress_ended_success(
|
|||||||
)
|
)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "stopped"
|
|
||||||
|
# NB: notify_external_services will return False, so status is "saved"
|
||||||
|
assert recording.status == "saved"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -155,7 +157,7 @@ def test_handle_egress_updated_non_handled(
|
|||||||
def test_handle_egress_ended_metadata_update_fails(
|
def test_handle_egress_ended_metadata_update_fails(
|
||||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording when metadata's update fails."""
|
"""Should successfully stop and save recording when metadata's update fails."""
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -170,7 +172,9 @@ def test_handle_egress_ended_metadata_update_fails(
|
|||||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
)
|
)
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "stopped"
|
|
||||||
|
# NB: notify_external_services will return False, so status is "saved"
|
||||||
|
assert recording.status == "saved"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@@ -326,6 +330,143 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
|||||||
mock_collector.stop.assert_not_called()
|
mock_collector.stop.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"egress_status",
|
||||||
|
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"notify_return_value, recording_status",
|
||||||
|
[(True, "notification_succeeded"), (False, "saved")],
|
||||||
|
)
|
||||||
|
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||||
|
mock_update_room_metadata,
|
||||||
|
mock_notify,
|
||||||
|
mock_notify_external_services,
|
||||||
|
notify_return_value,
|
||||||
|
recording_status,
|
||||||
|
egress_status,
|
||||||
|
service,
|
||||||
|
settings,
|
||||||
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
|
"""Should notify external services and save the recording on egress completion
|
||||||
|
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED) when RECORDING_STORAGE_EVENT_ENABLE is False.
|
||||||
|
"""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
mock_notify_external_services.return_value = notify_return_value
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = egress_status
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
mock_notify_external_services.assert_called_once_with(recording)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == recording_status
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch(
|
||||||
|
"core.recording.services.recording_events.notification_service."
|
||||||
|
"notify_external_services"
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"egress_status, expected_status",
|
||||||
|
[
|
||||||
|
(EgressStatus.EGRESS_COMPLETE, "active"),
|
||||||
|
(EgressStatus.EGRESS_LIMIT_REACHED, "stopped"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||||
|
mock_update_room_metadata,
|
||||||
|
mock_notify,
|
||||||
|
mock_notify_external_services,
|
||||||
|
egress_status,
|
||||||
|
expected_status,
|
||||||
|
service,
|
||||||
|
settings,
|
||||||
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
|
"""When storage event webhooks are enabled, egress_ended must not finalize the
|
||||||
|
recording: external services are never notified. EGRESS_LIMIT_REACHED still stops
|
||||||
|
the recording, EGRESS_COMPLETE leaves it active.
|
||||||
|
"""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = egress_status
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
mock_notify_external_services.assert_not_called()
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == expected_status
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"egress_status",
|
||||||
|
[
|
||||||
|
EgressStatus.EGRESS_STARTING,
|
||||||
|
EgressStatus.EGRESS_ACTIVE,
|
||||||
|
EgressStatus.EGRESS_ENDING,
|
||||||
|
EgressStatus.EGRESS_FAILED,
|
||||||
|
EgressStatus.EGRESS_ABORTED,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||||
|
mock_update_room_metadata, egress_status, service, settings
|
||||||
|
):
|
||||||
|
"""Shouldn't save on invalid status."""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = egress_status
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == "active"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||||
|
)
|
||||||
|
@mock.patch("core.utils.update_room_metadata")
|
||||||
|
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||||
|
mock_update_room_metadata, status, service, settings
|
||||||
|
):
|
||||||
|
"""Should handle non-savable recordings idempotently without raising.
|
||||||
|
|
||||||
|
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
||||||
|
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
|
||||||
|
"""
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
|
|
||||||
|
recording = RecordingFactory(worker_id="worker-1", status=status)
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
|
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||||
|
|
||||||
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
|
recording.refresh_from_db()
|
||||||
|
assert recording.status == status
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||||
|
|||||||
@@ -459,6 +459,41 @@ def generate_upload_policy(file):
|
|||||||
return policy
|
return policy
|
||||||
|
|
||||||
|
|
||||||
|
def generate_download_file_url(file, *, expires_in: int, override_domain: bool = True):
|
||||||
|
"""
|
||||||
|
Generate a S3 signed download url for a given file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = file.file_key
|
||||||
|
|
||||||
|
# This setting should be used if the backend application and the frontend application
|
||||||
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
# docker compose stack used in development. The frontend application will use localhost
|
||||||
|
# to connect to the object storage while the backend application will use the object storage
|
||||||
|
# service name declared in the docker compose stack.
|
||||||
|
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||||
|
# changed dynamically by the frontend application.
|
||||||
|
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
|
||||||
|
s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||||
|
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||||
|
config=botocore.client.Config(
|
||||||
|
region_name=settings.AWS_S3_REGION_NAME,
|
||||||
|
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
|
||||||
|
return s3_client.generate_presigned_url(
|
||||||
|
ClientMethod="get_object",
|
||||||
|
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||||
|
ExpiresIn=expires_in,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _format_telephony_phone_number(raw_number, default_country):
|
def _format_telephony_phone_number(raw_number, default_country):
|
||||||
"""Parse a configured phone number and return (country, international_format).
|
"""Parse a configured phone number and return (country, international_format).
|
||||||
|
|||||||
@@ -182,7 +182,9 @@ class Base(Configuration):
|
|||||||
environ_name="FILE_UPLOAD_ENABLED",
|
environ_name="FILE_UPLOAD_ENABLED",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
FILE_PURGE_GRACE_DAYS = values.PositiveIntegerValue(
|
||||||
|
default=7, environ_name="FILE_PURGE_GRACE_DAYS", environ_prefix=None
|
||||||
|
)
|
||||||
FILE_UPLOAD_PATH = values.Value(
|
FILE_UPLOAD_PATH = values.Value(
|
||||||
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
|
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -321,8 +323,7 @@ class Base(Configuration):
|
|||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
|
"core.authentication.backends.SessionAuthenticationWith401",
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
|
||||||
),
|
),
|
||||||
"DEFAULT_PARSER_CLASSES": [
|
"DEFAULT_PARSER_CLASSES": [
|
||||||
"rest_framework.parsers.JSONParser",
|
"rest_framework.parsers.JSONParser",
|
||||||
@@ -411,6 +412,12 @@ class Base(Configuration):
|
|||||||
"transcription_destination": values.Value(
|
"transcription_destination": values.Value(
|
||||||
None, environ_name="FRONTEND_TRANSCRIPTION_DESTINATION", environ_prefix=None
|
None, environ_name="FRONTEND_TRANSCRIPTION_DESTINATION", environ_prefix=None
|
||||||
),
|
),
|
||||||
|
"max_participants_for_sound": values.PositiveIntegerValue(
|
||||||
|
5, environ_name="FRONTEND_MAX_PARTICIPANTS_FOR_SOUND", environ_prefix=None
|
||||||
|
),
|
||||||
|
"auto_mute_on_join_threshold": values.PositiveIntegerValue(
|
||||||
|
50, environ_name="FRONTEND_AUTO_MUTE_ON_JOIN_THRESHOLD", environ_prefix=None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Mail
|
# Mail
|
||||||
@@ -754,6 +761,14 @@ class Base(Configuration):
|
|||||||
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
|
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
ANALYTICS_BACKEND = values.Value(
|
||||||
|
None, environ_name="ANALYTICS_BACKEND", environ_prefix=None
|
||||||
|
)
|
||||||
|
ANALYTICS_BACKEND_SETTINGS = values.DictValue(
|
||||||
|
{}, environ_name="ANALYTICS_BACKEND_SETTINGS", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# Marketing and communication settings
|
# Marketing and communication settings
|
||||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||||
False, # When enabled, new users are automatically added to mailing list.
|
False, # When enabled, new users are automatically added to mailing list.
|
||||||
|
|||||||
+28
-27
@@ -2,12 +2,12 @@
|
|||||||
# Meet package
|
# Meet package
|
||||||
#
|
#
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.10.9,<0.11.0"]
|
requires = ["uv_build>=0.11.16,<0.12.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.18.0"
|
version = "1.22.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,45 +24,46 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.42.68",
|
"boto3==1.43.36",
|
||||||
"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.3",
|
||||||
"dj-database-url==3.1.2",
|
"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==9.0.0",
|
||||||
"django-filter==25.2",
|
"django-filter==25.2",
|
||||||
"django-lasuite[all]==0.0.26",
|
"django-lasuite[all]==0.0.27",
|
||||||
"django-parler==2.3",
|
"django-parler==2.4",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"django-redis==6.0.0",
|
"django-redis==7.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.14",
|
"django==5.2.14",
|
||||||
"djangorestframework==3.16.1",
|
"djangorestframework==3.17.1",
|
||||||
"drf_spectacular==0.29.0",
|
"drf_spectacular==0.29.0",
|
||||||
"dockerflow==2026.3.4",
|
"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==26.0.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.3",
|
"posthog==7.16.1",
|
||||||
"pydantic==2.12.5",
|
"psycopg[binary]==3.3.4",
|
||||||
"PyJWT==2.12.1",
|
"pydantic==2.13.4",
|
||||||
"python-frontmatter==1.1.0",
|
"PyJWT==2.13.0",
|
||||||
|
"python-frontmatter==1.3.0",
|
||||||
"python-magic==0.4.27",
|
"python-magic==0.4.27",
|
||||||
"requests==2.33.0",
|
"requests==2.34.2",
|
||||||
"sentry-sdk==2.54.0",
|
"sentry-sdk==2.63.0",
|
||||||
"whitenoise==6.12.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.1",
|
||||||
"aiohttp==3.13.4",
|
"aiohttp==3.14.1",
|
||||||
"urllib3==2.7.0",
|
"urllib3==2.7.0",
|
||||||
"phonenumbers==9.0.30",
|
"phonenumbers==9.0.33",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -74,21 +75,21 @@ dependencies = [
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
"drf-spectacular-sidecar==2026.3.1",
|
"drf-spectacular-sidecar==2026.6.1",
|
||||||
"freezegun==1.5.5",
|
"freezegun==1.5.5",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==9.11.0",
|
"ipython==9.14.1",
|
||||||
"pyfakefs==6.1.5",
|
"pyfakefs==6.2.0",
|
||||||
"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.1.0",
|
||||||
"pytest-django==4.12.0",
|
"pytest-django==4.12.0",
|
||||||
"pytest==9.0.3",
|
"pytest==9.1.1",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.8.0",
|
"pytest-xdist==3.8.0",
|
||||||
"responses==0.26.0",
|
"responses==0.26.1",
|
||||||
"ruff==0.15.6",
|
"ruff==0.15.19",
|
||||||
"types-requests==2.32.4.20260107",
|
"types-requests==2.33.0.20260518",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.build-backend]
|
[tool.uv.build-backend]
|
||||||
|
|||||||
Generated
+737
-669
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: { browser: true, es2020: true },
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:react-hooks/recommended',
|
|
||||||
'plugin:@tanstack/eslint-plugin-query/recommended',
|
|
||||||
'plugin:jsx-a11y/recommended',
|
|
||||||
'prettier'
|
|
||||||
],
|
|
||||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'styled-system'],
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
plugins: ['react-refresh'],
|
|
||||||
rules: {
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -45,6 +45,8 @@ USER root
|
|||||||
|
|
||||||
# Security patches for known CVEs
|
# Security patches for known CVEs
|
||||||
RUN apk update && apk upgrade \
|
RUN apk update && apk upgrade \
|
||||||
|
libcrypto3>=3.5.7-r0 \
|
||||||
|
libssl3>=3.5.7-r0 \
|
||||||
musl \
|
musl \
|
||||||
musl-utils \
|
musl-utils \
|
||||||
zlib>=1.3.2-r0 \
|
zlib>=1.3.2-r0 \
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// src/frontend/eslint.config.js
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import reactPlugin from 'eslint-plugin-react'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||||
|
import globals from 'globals'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
// Global ignores (replaces .eslintignore)
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'dist/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'coverage/**',
|
||||||
|
'src/styled-system/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Base JS recommended rules
|
||||||
|
js.configs.recommended,
|
||||||
|
|
||||||
|
// TypeScript recommended rules (parser + rules bundled together)
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
|
||||||
|
// React-specific config
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
react: reactPlugin,
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
'jsx-a11y': jsxA11y,
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: { version: 'detect' },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// React rules
|
||||||
|
...reactPlugin.configs.recommended.rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react/react-in-jsx-scope': 'off', // not needed with React 17+
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
// React Compiler rules (v7+) — disable until migrated to React 19
|
||||||
|
'react-hooks/react-compiler': 'off',
|
||||||
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
|
'react-hooks/preserve-manual-memoization': 'off',
|
||||||
|
'react-hooks/immutability': 'off',
|
||||||
|
'react-hooks/refs': 'off',
|
||||||
|
|
||||||
|
// jsx-a11y recommended rules
|
||||||
|
...jsxA11y.configs.recommended.rules,
|
||||||
|
|
||||||
|
// TypeScript rules you may want to adjust
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// CJS and root-level JS config files (postcss.config.js, etc.)
|
||||||
|
{
|
||||||
|
files: ['**/*.cjs', '*.config.js'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
Generated
+3591
-4839
File diff suppressed because it is too large
Load Diff
+37
-33
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.18.0",
|
"version": "1.22.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
"build": "panda codegen && tsc -b && vite build",
|
"build": "panda codegen && tsc -b && vite build",
|
||||||
"build:debug": "VITE_ANALYZE=true npm run build -- --debug",
|
"build:debug": "VITE_ANALYZE=true npm run build -- --debug",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
||||||
"format": "prettier --write ./src",
|
"format": "prettier --write ./src",
|
||||||
@@ -16,56 +17,59 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
"@livekit/components-react": "2.9.19",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.0",
|
"@livekit/track-processors": "0.7.2",
|
||||||
"@pandacss/preset-panda": "1.8.2",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-aria/toast": "3.0.10",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@react-types/overlays": "3.9.3",
|
"@remixicon/react": "4.9.0",
|
||||||
"@remixicon/react": "4.6.0",
|
"@tanstack/react-query": "5.100.14",
|
||||||
"@tanstack/react-query": "5.90.21",
|
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.0.27",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.33.2",
|
||||||
"i18next": "25.8.8",
|
"i18next": "26.3.1",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.3.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.17.1",
|
"livekit-client": "2.19.0",
|
||||||
"posthog-js": "1.342.1",
|
"posthog-js": "1.386.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.14.0",
|
"react-aria": "3.49.0",
|
||||||
|
"react-aria-components": "1.18.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "15.1.1",
|
"react-i18next": "17.0.8",
|
||||||
|
"react-stately": "3.47.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.0",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.9.0"
|
"wouter": "3.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "9.39.4",
|
||||||
"@pandacss/dev": "1.11.1",
|
"@pandacss/dev": "1.11.1",
|
||||||
"@tanstack/eslint-plugin-query": "5.91.4",
|
"@tanstack/eslint-plugin-query": "5.100.14",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.100.14",
|
||||||
"@types/humanize-duration": "3.27.4",
|
"@types/humanize-duration": "3.27.4",
|
||||||
"@types/node": "22.16.0",
|
"@types/node": "24.12.4",
|
||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@typescript-eslint/eslint-plugin": "8.35.1",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
"@typescript-eslint/parser": "8.35.1",
|
"eslint": "9.39.4",
|
||||||
"@vitejs/plugin-react": "5.1.4",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint": "8.57.0",
|
|
||||||
"eslint-config-prettier": "10.1.5",
|
|
||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-refresh": "0.4.20",
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
"postcss": "8.5.14",
|
"eslint-plugin-react-refresh": "0.5.2",
|
||||||
"prettier": "3.8.1",
|
"globals": "17.6.0",
|
||||||
|
"postcss": "8.5.15",
|
||||||
|
"prettier": "3.8.3",
|
||||||
"rollup-plugin-visualizer": "7.0.1",
|
"rollup-plugin-visualizer": "7.0.1",
|
||||||
"typescript": "5.8.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "7.3.2",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite-plugin-svgr": "5.2.0",
|
"vite": "8.0.14",
|
||||||
"vite-tsconfig-paths": "6.1.1"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface ApiConfig {
|
|||||||
analytics?: {
|
analytics?: {
|
||||||
id: string
|
id: string
|
||||||
host: string
|
host: string
|
||||||
|
flags_api_host?: string
|
||||||
}
|
}
|
||||||
support?: {
|
support?: {
|
||||||
id: string
|
id: string
|
||||||
@@ -55,6 +56,8 @@ export interface ApiConfig {
|
|||||||
default_sources: Source[]
|
default_sources: Source[]
|
||||||
}
|
}
|
||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
|
max_participants_for_sound: number
|
||||||
|
auto_mute_on_join_threshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
|||||||
@@ -28,10 +28,16 @@ export const terminateAnalyticsSession = async () => {
|
|||||||
export type useAnalyticsProps = {
|
export type useAnalyticsProps = {
|
||||||
id?: string
|
id?: string
|
||||||
host?: string
|
host?: string
|
||||||
|
flags_api_host?: string
|
||||||
isDisabled?: boolean
|
isDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
export const useAnalytics = ({
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
flags_api_host,
|
||||||
|
isDisabled,
|
||||||
|
}: useAnalyticsProps) => {
|
||||||
const [location] = useLocation()
|
const [location] = useLocation()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
|
|
||||||
@@ -39,9 +45,13 @@ export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
|||||||
if (!id || !host || isDisabled) return
|
if (!id || !host || isDisabled) return
|
||||||
getPosthog().then((ph) => {
|
getPosthog().then((ph) => {
|
||||||
if (ph.__loaded) return
|
if (ph.__loaded) return
|
||||||
ph.init(id, { api_host: host, person_profiles: 'always' })
|
ph.init(id, {
|
||||||
|
api_host: host,
|
||||||
|
flags_api_host: flags_api_host,
|
||||||
|
person_profiles: 'always',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}, [id, host, isDisabled])
|
}, [id, host, flags_api_host, isDisabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ import { type ApiUser } from './ApiUser'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
|
const SILENT_LOGIN_PARAM = 'silentLogin'
|
||||||
|
|
||||||
|
const isSilentLoginDisabledByUrl = () => {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
const value = new URLSearchParams(window.location.search).get(
|
||||||
|
SILENT_LOGIN_PARAM
|
||||||
|
)
|
||||||
|
return value === 'false'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns info about currently logged-in user
|
* returns info about currently logged-in user
|
||||||
*
|
*
|
||||||
@@ -17,19 +27,24 @@ export const useUser = (
|
|||||||
) => {
|
) => {
|
||||||
const { data, isLoading: isConfigLoading } = useConfig()
|
const { data, isLoading: isConfigLoading } = useConfig()
|
||||||
|
|
||||||
|
const disabledByUrl = useMemo(() => isSilentLoginDisabledByUrl(), [])
|
||||||
|
|
||||||
const options = useMemo(() => {
|
const options = useMemo(() => {
|
||||||
if (isConfigLoading) return
|
if (isConfigLoading) return
|
||||||
if (data?.is_silent_login_enabled !== true) {
|
|
||||||
|
const silentDisabled =
|
||||||
|
data?.is_silent_login_enabled !== true || disabledByUrl
|
||||||
|
|
||||||
|
if (silentDisabled) {
|
||||||
return {
|
return {
|
||||||
...opts,
|
...opts.fetchUserOptions,
|
||||||
attemptSilent: false,
|
attemptSilent: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return opts.fetchUserOptions
|
return opts.fetchUserOptions
|
||||||
}, [data, opts, isConfigLoading])
|
}, [data, opts, isConfigLoading, disabledByUrl])
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
|
||||||
queryKey: [keys.user],
|
queryKey: [keys.user],
|
||||||
queryFn: () => fetchUser(options),
|
queryFn: () => fetchUser(options),
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type ApiFileCreator = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ApiFileType = 'background_image'
|
export type ApiFileType = 'background_image'
|
||||||
export type ApiFileUploadState = 'pending' | 'ready'
|
export type ApiFileUploadState = 'pending' | 'analyzing' | 'ready'
|
||||||
|
|
||||||
export type ApiFileItem = {
|
export type ApiFileItem = {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ export function PaginationControl({
|
|||||||
if (totalPageCount <= 1) return null
|
if (totalPageCount <= 1) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<nav
|
||||||
|
aria-label={t('label')}
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '1rem',
|
bottom: '1rem',
|
||||||
@@ -74,7 +75,7 @@ export function PaginationControl({
|
|||||||
<RiArrowLeftSLine />
|
<RiArrowLeftSLine />
|
||||||
</Button>
|
</Button>
|
||||||
<span
|
<span
|
||||||
aria-live="polite"
|
role="status"
|
||||||
className={css({
|
className={css({
|
||||||
padding: '0.25rem 0.5rem',
|
padding: '0.25rem 0.5rem',
|
||||||
})}
|
})}
|
||||||
@@ -93,6 +94,6 @@ export function PaginationControl({
|
|||||||
>
|
>
|
||||||
<RiArrowRightSLine />
|
<RiArrowRightSLine />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|||||||
import { Emoji } from '@/features/reactions/types'
|
import { Emoji } from '@/features/reactions/types'
|
||||||
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
||||||
import { NotificationProvider } from './NotificationProvider'
|
import { NotificationProvider } from './NotificationProvider'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
export const MainNotificationToast = () => {
|
export const MainNotificationToast = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
const { data } = useConfig()
|
||||||
const { triggerNotificationSound } = useNotificationSound()
|
const { triggerNotificationSound } = useNotificationSound()
|
||||||
const { t } = useTranslation('notifications')
|
const { t } = useTranslation('notifications')
|
||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
@@ -135,12 +137,21 @@ export const MainNotificationToast = () => {
|
|||||||
}
|
}
|
||||||
}, [room, handleEmoji])
|
}, [room, handleEmoji])
|
||||||
|
|
||||||
|
const triggerNotificationSoundIfRoomIsSmall = useCallback(
|
||||||
|
(type: NotificationType) => {
|
||||||
|
if (!data) return
|
||||||
|
if (room.numParticipants >= data.max_participants_for_sound) return
|
||||||
|
triggerNotificationSound(type)
|
||||||
|
},
|
||||||
|
[room, data, triggerNotificationSound]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const showJoinNotification = (participant: Participant) => {
|
const showJoinNotification = (participant: Participant) => {
|
||||||
if (isMobileBrowser()) {
|
if (isMobileBrowser()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
triggerNotificationSound(NotificationType.ParticipantJoined)
|
triggerNotificationSoundIfRoomIsSmall(NotificationType.ParticipantJoined)
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
{
|
{
|
||||||
participant,
|
participant,
|
||||||
@@ -155,7 +166,7 @@ export const MainNotificationToast = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
|
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
|
||||||
}
|
}
|
||||||
}, [room, triggerNotificationSound])
|
}, [room, triggerNotificationSoundIfRoomIsSmall])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeParticipantNotifications = (participant: Participant) => {
|
const removeParticipantNotifications = (participant: Participant) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export enum NotificationType {
|
export enum NotificationType {
|
||||||
|
AutoMuteLargeRoom = 'autoMuteLargeRoom',
|
||||||
ParticipantJoined = 'participantJoined',
|
ParticipantJoined = 'participantJoined',
|
||||||
HandRaised = 'handRaised',
|
HandRaised = 'handRaised',
|
||||||
ParticipantMuted = 'participantMuted',
|
ParticipantMuted = 'participantMuted',
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
export const StyledToast = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '10px',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
export const StyledToastContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
margin: 0.5,
|
||||||
|
boxShadow:
|
||||||
|
'rgba(0, 0, 0, 0.5) 0px 4px 8px 0px, rgba(0, 0, 0, 0.3) 0px 6px 20px 4px',
|
||||||
|
backgroundColor: 'greyscale.700',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
'&[data-entering]': { animation: 'fade 200ms' },
|
||||||
|
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
|
||||||
|
width: 'fit-content',
|
||||||
|
marginLeft: 'auto',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,36 +1,12 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import type { ToastState } from '@react-stately/toast'
|
import type { ToastState } from 'react-stately'
|
||||||
import type { ToastData } from './ToastProvider'
|
import type { ToastData } from './ToastProvider'
|
||||||
import type { QueuedToast } from '@react-stately/toast'
|
import type { QueuedToast } from 'react-stately'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
export const StyledToastContainer = styled('div', {
|
import { StyledToast } from './StyledToast'
|
||||||
base: {
|
|
||||||
margin: 0.5,
|
|
||||||
boxShadow:
|
|
||||||
'rgba(0, 0, 0, 0.5) 0px 4px 8px 0px, rgba(0, 0, 0, 0.3) 0px 6px 20px 4px',
|
|
||||||
backgroundColor: 'greyscale.700',
|
|
||||||
color: 'white',
|
|
||||||
borderRadius: '8px',
|
|
||||||
'&[data-entering]': { animation: 'fade 200ms' },
|
|
||||||
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
|
|
||||||
width: 'fit-content',
|
|
||||||
marginLeft: 'auto',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const StyledToast = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '1rem',
|
|
||||||
padding: '10px',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface ToastProps {
|
export interface ToastProps {
|
||||||
key: string
|
key: string
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
import type { ToastProps } from './Toast'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { NotificationType } from '../NotificationType'
|
import { NotificationType } from '../NotificationType'
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useToast } from 'react-aria'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
|
||||||
|
import { type ToastProps } from './Toast'
|
||||||
|
import { VStack } from '@/styled-system/jsx'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
|
||||||
|
export function ToastAutoMuteLargeRoom({
|
||||||
|
state,
|
||||||
|
...props
|
||||||
|
}: Readonly<ToastProps>) {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { t } = useTranslation('notifications', {
|
||||||
|
keyPrefix: 'autoMuteLargeRoom',
|
||||||
|
})
|
||||||
|
const ref = useRef(null)
|
||||||
|
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||||
|
const toast = props.toast
|
||||||
|
|
||||||
|
const handleDismiss = async () => {
|
||||||
|
room.localParticipant
|
||||||
|
.setMicrophoneEnabled(true)
|
||||||
|
.finally(() => state.close(toast.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
|
<VStack
|
||||||
|
justify="start"
|
||||||
|
alignItems="self-start"
|
||||||
|
{...contentProps}
|
||||||
|
maxWidth="370px"
|
||||||
|
gap="0.75rem"
|
||||||
|
padding={14}
|
||||||
|
>
|
||||||
|
<p>{t('auto')}</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="text"
|
||||||
|
className={css({
|
||||||
|
color: 'primary.300',
|
||||||
|
})}
|
||||||
|
onPress={() => handleDismiss()}
|
||||||
|
>
|
||||||
|
{t('dismiss')}
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</StyledToastContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
@@ -6,10 +6,11 @@ import Source = Track.Source
|
|||||||
|
|
||||||
import { useMaybeLayoutContext } from '@livekit/components-react'
|
import { useMaybeLayoutContext } from '@livekit/components-react'
|
||||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack, styled } from '@/styled-system/jsx'
|
import { HStack, styled } from '@/styled-system/jsx'
|
||||||
import { Div } from '@/primitives'
|
import { Div } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
|
||||||
const ClickableToast = styled(RACButton, {
|
const ClickableToast = styled(RACButton, {
|
||||||
base: {
|
base: {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
|
||||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
|
||||||
export function ToastLowerHand({ state, ...props }: Readonly<ToastProps>) {
|
export function ToastLowerHand({ state, ...props }: Readonly<ToastProps>) {
|
||||||
const { t } = useTranslation('notifications', { keyPrefix: 'lowerHand' })
|
const { t } = useTranslation('notifications', { keyPrefix: 'lowerHand' })
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import { RiMessage2Line } from '@remixicon/react'
|
import { RiMessage2Line } from '@remixicon/react'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
|
||||||
export function ToastMessageReceived({
|
export function ToastMessageReceived({
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
|
||||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
|
||||||
export function ToastMuted({ state, ...props }: Readonly<ToastProps>) {
|
export function ToastMuted({ state, ...props }: Readonly<ToastProps>) {
|
||||||
const { t } = useTranslation('notifications')
|
const { t } = useTranslation('notifications')
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
|
||||||
export function ToastPermissionsRemoved({
|
export function ToastPermissionsRemoved({
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { ToastQueue, useToastQueue } from '@react-stately/toast'
|
import { ToastQueue, useToastQueue } from 'react-stately'
|
||||||
import { ToastRegion } from './ToastRegion'
|
import { ToastRegion } from './ToastRegion'
|
||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
import type { NotificationType } from '../NotificationType'
|
import type { NotificationType } from '../NotificationType'
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
|
||||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { Button, Div } from '@/primitives'
|
import { Button, Div } from '@/primitives'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiCloseLine, RiHand } from '@remixicon/react'
|
import { RiCloseLine, RiHand } from '@remixicon/react'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
|
||||||
export function ToastRaised({ state, ...props }: Readonly<ToastProps>) {
|
export function ToastRaised({ state, ...props }: Readonly<ToastProps>) {
|
||||||
const { t } = useTranslation('notifications')
|
const { t } = useTranslation('notifications')
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
|
|
||||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { NotificationType } from '../NotificationType'
|
import { NotificationType } from '../NotificationType'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
|
||||||
export function ToastRecordingRequest({
|
export function ToastRecordingRequest({
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useToast } from '@react-aria/toast'
|
import { useToast } from 'react-aria'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
import { StyledToastContainer, type ToastProps } from './Toast'
|
import { type ToastProps } from './Toast'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { RecordingMode } from '@/features/recording'
|
import { RecordingMode } from '@/features/recording'
|
||||||
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
|
|
||||||
export function ToastRecordingSaving({
|
export function ToastRecordingSaving({
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
|
import { AriaToastRegionProps, useToastRegion } from 'react-aria'
|
||||||
import type { QueuedToast, ToastState } from '@react-stately/toast'
|
import type { QueuedToast, ToastState } from 'react-stately'
|
||||||
import { Toast } from './Toast'
|
import { Toast } from './Toast'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { NotificationType } from '../NotificationType'
|
import { NotificationType } from '../NotificationType'
|
||||||
@@ -13,6 +13,7 @@ import { ToastAnyRecording } from './ToastAnyRecording'
|
|||||||
import { ToastRecordingSaving } from './ToastRecordingSaving'
|
import { ToastRecordingSaving } from './ToastRecordingSaving'
|
||||||
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||||
|
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||||
|
|
||||||
interface ToastRegionProps extends AriaToastRegionProps {
|
interface ToastRegionProps extends AriaToastRegionProps {
|
||||||
state: ToastState<ToastData>
|
state: ToastState<ToastData>
|
||||||
@@ -45,6 +46,11 @@ const renderToast = (
|
|||||||
case NotificationType.LowerHand:
|
case NotificationType.LowerHand:
|
||||||
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
|
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
|
||||||
|
|
||||||
|
case NotificationType.AutoMuteLargeRoom:
|
||||||
|
return (
|
||||||
|
<ToastAutoMuteLargeRoom key={toast.key} toast={toast} state={state} />
|
||||||
|
)
|
||||||
|
|
||||||
case NotificationType.TranscriptionStarted:
|
case NotificationType.TranscriptionStarted:
|
||||||
case NotificationType.TranscriptionStopped:
|
case NotificationType.TranscriptionStopped:
|
||||||
case NotificationType.TranscriptionLimitReached:
|
case NotificationType.TranscriptionLimitReached:
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { StyledToastContainer } from './Toast'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
import { HStack, VStack } from '@/styled-system/jsx'
|
import { HStack, VStack } from '@/styled-system/jsx'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { Button, Text } from '@/primitives'
|
import { Button, Text } from '@/primitives'
|
||||||
@@ -22,7 +22,7 @@ export const WaitingParticipantNotification = () => {
|
|||||||
keyPrefix: 'waitingParticipants',
|
keyPrefix: 'waitingParticipants',
|
||||||
})
|
})
|
||||||
|
|
||||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||||
const [showQuickActionsMessage, setShowQuickActionsMessage] = useState(false)
|
const [showQuickActionsMessage, setShowQuickActionsMessage] = useState(false)
|
||||||
const { waitingParticipants, handleParticipantEntry } =
|
const { waitingParticipants, handleParticipantEntry } =
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import type { Participant } from 'livekit-client'
|
|||||||
import type { NotificationPayload } from './NotificationPayload'
|
import type { NotificationPayload } from './NotificationPayload'
|
||||||
import type { RecordingMode } from '@/features/recording'
|
import type { RecordingMode } from '@/features/recording'
|
||||||
|
|
||||||
|
export const notifyAutoMutedOnJoin = () => {
|
||||||
|
toastQueue.add(
|
||||||
|
{
|
||||||
|
type: NotificationType.AutoMuteLargeRoom,
|
||||||
|
},
|
||||||
|
{ timeout: NotificationDuration.ALERT }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const showLowerHandToast = (
|
export const showLowerHandToast = (
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
|
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { reactionsStore } from '@/stores/reactions'
|
import { reactionsStore } from '@/stores/reactions'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { FloatingReaction } from '@/features/reactions/components/ReactionPortals'
|
import { FloatingReaction } from '@/features/reactions/components/ReactionPortals'
|
||||||
import { Reaction } from '@/features/reactions/types'
|
import { Reaction } from '@/features/reactions/types'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
@@ -19,8 +19,8 @@ export const PipFloatingReactions = () => {
|
|||||||
|
|
||||||
const PipFloatingReaction = ({ reaction }: { reaction: Reaction }) => {
|
const PipFloatingReaction = ({ reaction }: { reaction: Reaction }) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
|
const [speed] = useState(() => Math.random() * 1.5 + 0.5)
|
||||||
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
const [scale] = useState(() => Math.max(Math.random() + 0.5, 1))
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const PipView = () => {
|
|||||||
</ConnectionStateWrapper>
|
</ConnectionStateWrapper>
|
||||||
<PipStage />
|
<PipStage />
|
||||||
<ReactionsToolbar adjustedCentering={false} />
|
<ReactionsToolbar adjustedCentering={false} />
|
||||||
<PipControlBar showScreenShare={false} />
|
<PipControlBar showScreenShare={true} />
|
||||||
<PipFloatingReactions />
|
<PipFloatingReactions />
|
||||||
<NotificationProvider bottom={30} />
|
<NotificationProvider bottom={30} />
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { RiMoreFill } from '@remixicon/react'
|
import { RiMoreFill } from '@remixicon/react'
|
||||||
import { FocusScope } from '@react-aria/focus'
|
import { FocusScope } from 'react-aria'
|
||||||
import { Box, Button } from '@/primitives'
|
import { Box, Button } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -116,7 +116,7 @@ export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
|
||||||
<FocusScope autoFocus>
|
<FocusScope autoFocus>
|
||||||
<Box size="sm" type="popover" variant="dark">
|
<Box size="sm" type="popover" variant="dark">
|
||||||
<PipOptionsMenuItems overflowControls={overflowControls} />
|
<PipOptionsMenuItems overflowControls={overflowControls} />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user