mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 724a4ef3df |
@@ -48,12 +48,12 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# with:
|
||||
# docker-build-args: '--target backend-production -f Dockerfile'
|
||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -93,12 +93,12 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# with:
|
||||
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -139,12 +139,12 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# with:
|
||||
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -185,13 +185,13 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# continue-on-error: true
|
||||
# with:
|
||||
# docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||
docker-context: './src/summary'
|
||||
-
|
||||
name: Build and push
|
||||
@@ -233,14 +233,14 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||
docker-context: './src/agents'
|
||||
# -
|
||||
# name: Run trivy scan
|
||||
# uses: numerique-gouv/action-trivy-cache@main
|
||||
# continue-on-error: true
|
||||
# with:
|
||||
# docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||
# docker-context: './src/agents'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
@@ -297,46 +297,6 @@ jobs:
|
||||
- name: Run tests
|
||||
run: uv run pytest -n 2
|
||||
|
||||
test-summary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/summary
|
||||
|
||||
env:
|
||||
APP_API_TOKEN: "test-api-token"
|
||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||
AWS_S3_SECRET_ACCESS_KEY: "password"
|
||||
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
||||
WHISPERX_ASR_MODEL: "large-v2"
|
||||
WHISPERX_API_KEY: "test-whisperx-secret"
|
||||
WHISPERX_DEFAULT_LANGUAGE: "fr"
|
||||
LLM_BASE_URL: "https://configure-your-url.com"
|
||||
LLM_API_KEY: "test-llm-secret"
|
||||
LLM_MODEL: "test-llm-model"
|
||||
WEBHOOK_API_TOKEN: "test-webhook-secret"
|
||||
WEBHOOK_URL: "https://configure-your-url.com"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
- name: Run summary tests
|
||||
run: ~/.local/bin/pytest
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
+2
-42
@@ -8,41 +8,6 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dependencies) update python dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) fix email disclosure in room invitation endpoint #1200
|
||||
- 🐛(backend) fix regression in update-participant endpoint #1204
|
||||
|
||||
## [1.12.0] - 2026-03-24
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(backend) configurable SESSION_ENGINE #1038 #1154
|
||||
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
|
||||
- ♿️(frontend) fix more tools heading hierarchy #1181
|
||||
- ♿️(fronted) improve button descriptions for More tools actions #1184
|
||||
- 💄(spinner) enforce spinner height #1183
|
||||
- 💄(custom-background) add upload indicator with preview #1183
|
||||
- ♿️(backend) improve logo accessibility in recording email notification #1092
|
||||
- ♿️(summary) improve accessibility of transcription download link #1187
|
||||
- 💄(frontend) show OS-specific shortcut in participant tile hint #1193
|
||||
- ⬆️(frontend) bump flatted from 3.3.1 to 3.4.2 in /src/frontend #1188
|
||||
- ⬆️️️(frontend) bump undici from 6.23.0 to 6.24.1 in /src/frontend
|
||||
- ⬆️️️(frontend) bump hono from 4.12.2 to 4.12.7 in /src/frontend
|
||||
- ⬆️️️(frontend) bump dompurify from 3.3.1 to 3.3.2 in /src/frontend
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) disable personal custom background while deleting #1183
|
||||
- 🐛(frontend) auto-select new custom background when not logged in #1183
|
||||
- 🐛(frontend) fix device selection not applying during conference #1156
|
||||
|
||||
## [1.11.0] - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) support celery with our Django backend #1124
|
||||
@@ -50,24 +15,20 @@ and this project adheres to
|
||||
- ✨(backend) add authenticated user rate throttling on request-entry #1129
|
||||
- ✨(backend) expose `is_active` field for Application in Django admin #1133
|
||||
- ✨(file-upload) disable by default & limit count by user #1141
|
||||
- ✨(frontend) custom background #1067
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) Caption text size setting for accessibility #1062
|
||||
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
|
||||
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
|
||||
- ♿️(frontend) improve chat toast a11y for screen readers #1109
|
||||
- ♿️(frontend) improve ui and aria labels for help article links #1108
|
||||
- ♿(frontend) improve chat toast a11y for screen readers #1109
|
||||
- ♿(frontend) improve ui and aria labels for help article links #1108
|
||||
- 🌐(frontend) improve German translation #1125
|
||||
- 🔨(python-env) migrate meet main app to UV #1120
|
||||
- ♻️(backend) align Application model field with `is_active` convention #1133
|
||||
- 🔐(backend) avoids revealing the inactive status of an application #1135
|
||||
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
|
||||
- 🔒️(backend) avoid information exposure through exception messages #1144
|
||||
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
|
||||
- 📌(agents) unpin OpenSSL and related dependencies #1167
|
||||
- ♿️(frontend) add caption font and background color customization #1122
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -75,7 +36,6 @@ and this project adheres to
|
||||
- 🩹(backend) add page_size to pagination for room endpoints #1131
|
||||
- 🐛(backend) refactor lobby throttling to use participant id #1129
|
||||
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
|
||||
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
|
||||
|
||||
## [1.10.0] - 2026-03-05
|
||||
|
||||
|
||||
@@ -125,16 +125,10 @@ run-summary: ## start only the summary application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
||||
.PHONY: run-summary
|
||||
|
||||
run-sip: ## start the SIP gateway and the browser-based softphone (Janus + nginx)
|
||||
@$(COMPOSE) up --force-recreate -d sip
|
||||
@$(COMPOSE) up --force-recreate -d sip-web-janus sip-web
|
||||
.PHONY: run-sip
|
||||
|
||||
run:
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(MAKE) run-backend
|
||||
@$(MAKE) run-summary
|
||||
@$(MAKE) run-sip
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
@@ -197,7 +191,6 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
||||
|
||||
test: ## run project tests
|
||||
@$(MAKE) test-back-parallel
|
||||
@$(MAKE) test-summary
|
||||
.PHONY: test
|
||||
|
||||
test-back: ## run back-end tests
|
||||
@@ -210,11 +203,6 @@ test-back-parallel: ## run all back-end tests in parallel
|
||||
bin/pytest -n auto $${args:-${1}}
|
||||
.PHONY: test-back-parallel
|
||||
|
||||
test-summary: ## run summary tests
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest-summary $${args:-${1}}
|
||||
.PHONY: test-summary
|
||||
|
||||
makemigrations: ## run django makemigrations for the Meet project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/suitenumerique/meet/stargazers/">
|
||||
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
|
||||
@@ -11,11 +12,11 @@
|
||||
<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">
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||
</a>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -27,65 +28,25 @@
|
||||
## 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/).
|
||||
|
||||
### Features
|
||||
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- End-to-end encryption with passphrase-in-link key distribution
|
||||
- End-to-end encryption (coming soon)
|
||||
- Meeting recording
|
||||
- Meeting transcription & Summary (currently in beta)
|
||||
- Telephony integration
|
||||
- Secure participation with robust authentication and access control
|
||||
- Customizable frontend style
|
||||
- LiveKit Advances features including :
|
||||
- speaker detection
|
||||
- simulcast
|
||||
- end-to-end optimizations
|
||||
- speaker detection
|
||||
- simulcast
|
||||
- end-to-end optimizations
|
||||
- selective subscription
|
||||
- SVC codecs (VP9, AV1)
|
||||
|
||||
### End-to-end encryption
|
||||
|
||||
La Suite Meet supports end-to-end encryption (E2EE) for meetings, so the media server (LiveKit SFU) cannot read audio, video or screen-share content.
|
||||
|
||||
#### How it works
|
||||
|
||||
- Each encrypted meeting carries a 48-character random passphrase appended to the URL hash (`#…`). The server never sees it; sharing the meeting link shares the key.
|
||||
- Frames are encrypted in the browser via LiveKit's Worker + `crypto.subtle` (AES-GCM); only the media payload is encrypted, codec headers stay clear so the SFU can still packetize RTP.
|
||||
- The runtime "is this call encrypted?" decision keys off the URL hash, not the database flag — a compromised server cannot fabricate a passphrase that all participants happen to share.
|
||||
- The DB flag (`Room.is_encrypted`) is a hint used at room creation time only (so the Create button knows to generate a hash) and to detect link/server inconsistencies.
|
||||
|
||||
#### Opt-in by user
|
||||
|
||||
End-to-end encryption is a per-user preference. In **Settings → Security**, signed-in users can enable "End-to-end encryption" — from then on every meeting they create is encrypted by default. Joining is unaffected: if a meeting URL has a passphrase, the joining client uses it.
|
||||
|
||||
#### Pause / resume for recording and transcription
|
||||
|
||||
While encryption is on, the SFU cannot record or transcribe (it has nothing to read). When an admin (or, if no admin is present, the longest-present participant — provided a pause has already been observed in the session) starts a recording or transcription:
|
||||
|
||||
1. A confirmation dialog warns that encryption will be paused.
|
||||
2. On confirm, an `ENCRYPTION_PAUSED` message is broadcast over a LiveKit reliable data channel. While the sender hasn't yet flipped its own state, that message is itself encrypted — which is the trust anchor: only callers holding the passphrase can produce frames everyone can decrypt.
|
||||
3. Each receiver disables E2EE locally and republishes its tracks unencrypted.
|
||||
4. Late joiners send an `ENCRYPTION_STATUS_PROBE` so the leader can re-emit the announcement to them.
|
||||
5. When **both** recording and transcription stop, the participant who paused broadcasts `ENCRYPTION_RESUMED` and everyone re-enables E2EE with the same URL passphrase.
|
||||
|
||||
The pause state is intentionally session-only and never persisted — `Room.is_encrypted` does not flip.
|
||||
|
||||
#### Phone / SIP participants
|
||||
|
||||
Phone and other external devices can't decrypt our frames. When one joins an encrypted room, the backend webhook detects them, broadcasts a system notice (admins see a snackbar with an "Open settings" CTA), and removes the external participant. The admin can then disable encryption from the Security settings and the user can dial in again.
|
||||
|
||||
#### Configuration
|
||||
|
||||
```env
|
||||
ENCRYPTION_ENABLED=true
|
||||
```
|
||||
|
||||
Setting `ENCRYPTION_ENABLED=false` disables the user preference toggle entirely; existing encrypted rooms stay encrypted but no new ones can be created.
|
||||
|
||||
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||
|
||||
@@ -102,6 +63,7 @@ On the 25th of January 2026, David Amiel, France’s Minister for Civil Service
|
||||
- [Philosophy](#philosophy)
|
||||
- [Open source](#open-source)
|
||||
|
||||
|
||||
## Get started
|
||||
|
||||
## Docs
|
||||
@@ -120,15 +82,15 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
|
||||
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
|
||||
|
||||
#### 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🙏
|
||||
|
||||
| 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.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
| 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.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -138,6 +100,7 @@ We <3 contributions of any kind, big and small:
|
||||
- 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)
|
||||
|
||||
|
||||
## Philosophy
|
||||
|
||||
We’re relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
|
||||
@@ -146,6 +109,7 @@ Our users come first. We’re committed to making La Suite Meet as accessible an
|
||||
|
||||
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
|
||||
|
||||
|
||||
## Open-source
|
||||
|
||||
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
|
||||
@@ -157,13 +121,14 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
|
||||
## Contributors 🧞
|
||||
|
||||
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
|
||||
</a>
|
||||
|
||||
## Credits
|
||||
## Credits
|
||||
|
||||
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
|
||||
This project is tested with BrowserStack.
|
||||
@@ -172,3 +137,4 @@ This project is tested with BrowserStack.
|
||||
|
||||
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
|
||||
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
app-summary-dev \
|
||||
python -m pytest "$@"
|
||||
+2
-55
@@ -168,7 +168,8 @@ services:
|
||||
- "3000:8080"
|
||||
|
||||
dockerize:
|
||||
image: powerman/dockerize:0.19.0
|
||||
image: jwilder/dockerize
|
||||
platform: linux/x86_64
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:4.0.0
|
||||
@@ -245,60 +246,6 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
# SIP gateway. Built from the sibling livekit-sip checkout so any dev
|
||||
# cloning both repos gets a working `docker compose up`. TODO: replace
|
||||
# the build: block with `image: livekit/sip` once upstream ships an
|
||||
# image with the team's video bridging.
|
||||
sip:
|
||||
build:
|
||||
context: ../livekit-sip
|
||||
dockerfile: build/sip/Dockerfile
|
||||
ports:
|
||||
- "5060:5060/udp"
|
||||
- "5060:5060/tcp"
|
||||
- "10000-10020:10000-10020/udp"
|
||||
environment:
|
||||
SIP_CONFIG_BODY: |
|
||||
api_key: 'devkey'
|
||||
api_secret: 'secret'
|
||||
ws_url: 'ws://livekit:7880'
|
||||
redis:
|
||||
address: 'redis:6379'
|
||||
sip_port: 5060
|
||||
rtp_port: 10000-10020
|
||||
use_external_ip: false
|
||||
logging:
|
||||
level: debug
|
||||
depends_on:
|
||||
- livekit
|
||||
- redis
|
||||
|
||||
# Janus WebRTC gateway with the SIP plugin. Bridges a browser SIP demo
|
||||
# tab to the SIP gateway above. ICE-TCP enabled (janus.jcfg) so media
|
||||
# works under Lima port-forwarding without vmnet.
|
||||
sip-web-janus:
|
||||
build: ./docker/janus
|
||||
ports:
|
||||
- "10100-10120:10100-10120/tcp"
|
||||
volumes:
|
||||
- ./docker/janus/conf/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro
|
||||
- ./docker/janus/conf/janus.transport.http.jcfg:/usr/local/etc/janus/janus.transport.http.jcfg:ro
|
||||
- ./docker/janus/conf/janus.plugin.sip.jcfg:/usr/local/etc/janus/janus.plugin.sip.jcfg:ro
|
||||
depends_on:
|
||||
- sip
|
||||
|
||||
# Serves docker/janus/web/{index.html,janus.js} and proxies /janus to
|
||||
# the Janus container so the demo is single-origin.
|
||||
sip-web:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8088:80"
|
||||
volumes:
|
||||
- ./docker/janus/web:/usr/share/nginx/html:ro
|
||||
- ./docker/janus/conf/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- sip-web-janus
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
ports:
|
||||
|
||||
+3
-20
@@ -60,7 +60,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user.test@chromium.test",
|
||||
"email": "user@chromium.e2e",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": "true",
|
||||
@@ -74,7 +74,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user.test@webkit.test",
|
||||
"email": "user@webkit.e2e",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": "true",
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user.test@firefox.test",
|
||||
"email": "user@firefox.e2e",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"enabled": "true",
|
||||
@@ -845,23 +845,6 @@
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "encryption",
|
||||
"name": "Encryption Service",
|
||||
"enabled": true,
|
||||
"publicClient": true,
|
||||
"standardFlowEnabled": true,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"redirectUris": [
|
||||
"http://encryption.localhost:7200/auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://encryption.localhost:7200",
|
||||
"http://data.encryption.localhost:7200"
|
||||
],
|
||||
"protocol": "openid-connect",
|
||||
"fullScopeAllowed": true
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# Build Janus from source so ICE-TCP support is present (the canyan image
|
||||
# was compiled against libnice 0.1.16, which lacks the runtime ICE-TCP
|
||||
# capability Janus 1.1.x feature-detects at startup). Debian 13 ships
|
||||
# libnice 0.1.21+ which has ICE-TCP. Also: builds natively on arm64, so
|
||||
# no QEMU emulation overhead.
|
||||
FROM debian:13-slim AS builder
|
||||
|
||||
ARG JANUS_VERSION=v1.1.4
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential ca-certificates git pkg-config \
|
||||
autoconf automake libtool gengetopt \
|
||||
libssl-dev libsrtp2-dev libglib2.0-dev libopus-dev libogg-dev \
|
||||
libcurl4-openssl-dev libconfig-dev libnice-dev \
|
||||
libmicrohttpd-dev libjansson-dev libsofia-sip-ua-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
RUN git clone --depth 1 --branch ${JANUS_VERSION} \
|
||||
https://github.com/meetecho/janus-gateway.git janus
|
||||
|
||||
WORKDIR /src/janus
|
||||
RUN ./autogen.sh && \
|
||||
./configure \
|
||||
--prefix=/opt/janus \
|
||||
--disable-rabbitmq --disable-mqtt --disable-nanomsg --disable-unix-sockets \
|
||||
--disable-data-channels \
|
||||
--disable-all-plugins \
|
||||
--enable-plugin-sip \
|
||||
--disable-all-handlers \
|
||||
--disable-all-transports --enable-rest && \
|
||||
make -j$(nproc) && \
|
||||
make install && \
|
||||
make configs
|
||||
|
||||
FROM debian:13-slim
|
||||
|
||||
# Debian 13 (trixie) renamed several libs in the time_t-64 transition:
|
||||
# libglib2.0-0t64, libcurl4t64. libconfig9 → libconfig11.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 libsrtp2-1 libglib2.0-0t64 libopus0 libogg0 \
|
||||
libcurl4t64 libconfig11 libnice10 libmicrohttpd12 libjansson4 \
|
||||
libsofia-sip-ua0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /opt/janus /opt/janus
|
||||
|
||||
# Make paths match canyan's so the rest of the compose mounts (configs, demos)
|
||||
# Just-Work without changes. The configs we mount in docker/janus/conf/ assume
|
||||
# /usr/local/etc/janus and /usr/local/lib/janus.
|
||||
RUN ln -s /opt/janus/bin/janus /usr/local/bin/janus && \
|
||||
ln -s /opt/janus/etc/janus /usr/local/etc/janus && \
|
||||
ln -s /opt/janus/lib/janus /usr/local/lib/janus && \
|
||||
ln -s /opt/janus/share/janus /usr/local/share/janus
|
||||
|
||||
EXPOSE 8088 10100-10120/tcp 10100-10120/udp
|
||||
|
||||
CMD ["/usr/local/bin/janus"]
|
||||
@@ -1,42 +0,0 @@
|
||||
// Minimal Janus config for browser-↔-SIP demo. Trimmed from the upstream
|
||||
// sample to the bits we actually need.
|
||||
general: {
|
||||
configs_folder = "/usr/local/etc/janus"
|
||||
plugins_folder = "/usr/local/lib/janus/plugins"
|
||||
transports_folder = "/usr/local/lib/janus/transports"
|
||||
events_folder = "/usr/local/lib/janus/events"
|
||||
log_to_stdout = true
|
||||
debug_level = 4
|
||||
server_name = "meet-sip-demo"
|
||||
}
|
||||
|
||||
media: {
|
||||
// Janus ↔ Browser media (WebRTC over TCP — Lima only forwards TCP).
|
||||
rtp_port_range = "10100-10120"
|
||||
ipv6 = false
|
||||
}
|
||||
|
||||
nat: {
|
||||
// The whole reason this works without vmnet — Janus offers TCP ICE
|
||||
// candidates the Mac browser can actually connect to.
|
||||
ice_tcp = true
|
||||
// libnice quirk: ICE-TCP needs ICE Lite mode on the server side or
|
||||
// the connectivity check state machine deadlocks. Janus warns about
|
||||
// this at startup if Lite is off while TCP is on.
|
||||
ice_lite = true
|
||||
// Janus advertises this IP in ICE candidates. From the Mac browser's
|
||||
// perspective, the Janus daemon lives at 127.0.0.1:<port> (Lima
|
||||
// forwards Mac:127.0.0.1:<port> → VM → docker port-map → container).
|
||||
nat_1_1_mapping = "127.0.0.1"
|
||||
keep_private_host = true
|
||||
}
|
||||
|
||||
plugins: {
|
||||
// Only keep the SIP plugin. Everything else trimmed for boot time.
|
||||
disable = "libjanus_audiobridge.so,libjanus_videoroom.so,libjanus_streaming.so,libjanus_textroom.so,libjanus_recordplay.so,libjanus_voicemail.so,libjanus_echotest.so,libjanus_videocall.so,libjanus_nosip.so,libjanus_duktape.so,libjanus_lua.so"
|
||||
}
|
||||
|
||||
transports: {
|
||||
// Only HTTP. WS is overkill for the demo and adds another port.
|
||||
disable = "libjanus_websockets.so,libjanus_mqtt.so,libjanus_nanomsg.so,libjanus_rabbitmq.so,libjanus_pfunix.so"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// SIP plugin defaults. RTP range for the SIP leg (Janus ↔ livekit/sip)
|
||||
// is kept separate from WebRTC range — both legs are inside the docker
|
||||
// bridge network, so any UDP range works.
|
||||
general: {
|
||||
local_ip = "0.0.0.0"
|
||||
rtp_port_range = "20000-20100"
|
||||
events = true
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// HTTP transport: bind 0.0.0.0:8088 inside the container, served to the
|
||||
// browser through the nginx sidecar that also hosts the demo HTML.
|
||||
general: {
|
||||
json = "indented"
|
||||
base_path = "/janus"
|
||||
http = true
|
||||
port = 8088
|
||||
https = false
|
||||
mhd_connection_limit = 1020
|
||||
}
|
||||
|
||||
admin: {
|
||||
admin_base_path = "/admin"
|
||||
admin_http = false
|
||||
admin_https = false
|
||||
}
|
||||
|
||||
certificates: {
|
||||
}
|
||||
|
||||
cors: {
|
||||
// Same-origin via nginx proxy; demo page and API share a host.
|
||||
# allow_origin = "*"
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Docker's embedded DNS (127.0.0.11). Without this, nginx resolves
|
||||
# upstreams once at startup and breaks the moment a peer container is
|
||||
# recreated with a new bridge IP. The `valid=10s` plus a variable in
|
||||
# proxy_pass forces per-request re-resolution.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Serve the Janus demo HTML/JS extracted from the canyan image.
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Janus HTTP API — same origin as the demo pages so the default
|
||||
# settings.js URL ("http://<host>:8088/janus") just works. Regex so
|
||||
# /janus and /janus/<session>/... proxy, but /janus.js still hits
|
||||
# the static file root.
|
||||
location ~ ^/janus(/|$) {
|
||||
# Variable upstream + the resolver above = re-DNS on each request.
|
||||
# Plain `proxy_pass http://sip-web-janus:8088` would cache the IP
|
||||
# for the lifetime of the worker process.
|
||||
set $janus_upstream sip-web-janus;
|
||||
proxy_pass http://$janus_upstream:8088;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Minimal SIP-via-Janus demo. Replaces the upstream siptest.html bundle with
|
||||
a single self-contained page. Everything is auto-driven: page load creates
|
||||
the Janus session, attaches to the SIP plugin, registers as guest, places
|
||||
the call. The user just needs to type the room PIN on the dialpad.
|
||||
|
||||
Co-located only with janus.js (vendor lib, ~113KB) — no settings.js, no
|
||||
navbar/footer, no shared CSS, no logos. nginx serves these two files.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SIP Demo</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #1b1b1f; color: #e8e8e8;
|
||||
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
#app { display: flex; flex-direction: column; height: 100vh; max-width: 720px; margin: 0 auto; padding: 16px; gap: 12px; }
|
||||
h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||
#status { padding: 8px 12px; border-radius: 6px; background: #2c2c33; font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
#video-wrap { position: relative; flex: 1; background: #000; border-radius: 8px; overflow: hidden; min-height: 240px; }
|
||||
#remoteVideo { width: 100%; height: 100%; object-fit: contain; }
|
||||
#noVideo { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #555; font-size: 14px; }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
button { font: inherit; padding: 8px 14px; border: 0; border-radius: 6px; background: #4a90e2; color: #fff; cursor: pointer; }
|
||||
button:hover:not(:disabled) { background: #357ec0; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
button.danger { background: #dc3545; }
|
||||
button.danger:hover:not(:disabled) { background: #b62733; }
|
||||
button.success { background: #28a745; }
|
||||
button.success:hover:not(:disabled) { background: #1f8035; }
|
||||
.dialpad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
|
||||
.dialpad button { padding: 14px 0; font-size: 16px; background: #3a3a44; }
|
||||
.dialpad button:hover:not(:disabled) { background: #4a4a55; }
|
||||
#pin-row input { flex: 1; padding: 8px 12px; border: 1px solid #444; border-radius: 6px; background: #2c2c33; color: #fff; font: inherit; font-family: ui-monospace, monospace; letter-spacing: 0.2em; }
|
||||
#log { font-family: ui-monospace, monospace; font-size: 11px; max-height: 120px; overflow-y: auto; background: #15151a; padding: 8px; border-radius: 6px; color: #888; }
|
||||
.log-line { white-space: pre-wrap; word-break: break-word; }
|
||||
.log-err { color: #ff7373; }
|
||||
.disabled { opacity: 0.35; pointer-events: none; transition: opacity 0.2s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<h1>SIP demo (browser → Janus → livekit-sip → room)</h1>
|
||||
<div id="status">Loading…</div>
|
||||
|
||||
<div id="video-wrap">
|
||||
<video id="remoteVideo" autoplay playsinline muted></video>
|
||||
<audio id="remoteAudio" autoplay></audio>
|
||||
<div id="noVideo">No remote video yet</div>
|
||||
</div>
|
||||
|
||||
<div id="pin-row" class="row" style="display:none">
|
||||
<input type="text" id="pin" placeholder="Enter PIN then press # on the pad" inputmode="numeric" pattern="[0-9]*">
|
||||
<button id="sendPin" class="success">Send PIN</button>
|
||||
</div>
|
||||
|
||||
<div id="dialpad" class="dialpad" style="display:none">
|
||||
<button data-d="1">1</button><button data-d="2">2</button><button data-d="3">3</button><button data-d="A">A</button>
|
||||
<button data-d="4">4</button><button data-d="5">5</button><button data-d="6">6</button><button data-d="B">B</button>
|
||||
<button data-d="7">7</button><button data-d="8">8</button><button data-d="9">9</button><button data-d="C">C</button>
|
||||
<button data-d="*">*</button><button data-d="0">0</button><button data-d="#">#</button><button data-d="D">D</button>
|
||||
</div>
|
||||
|
||||
<div id="controls" class="row">
|
||||
<button id="muteBtn" class="danger">🔇 Mic muted</button>
|
||||
<button id="cameraBtn" class="success">📷 Camera on</button>
|
||||
<button id="speakerBtn" class="success">🔊 Speaker on</button>
|
||||
<button id="hangupBtn" class="danger" disabled>Hangup</button>
|
||||
<button id="rejoinBtn" style="display:none">Re-join</button>
|
||||
</div>
|
||||
|
||||
<details><summary style="cursor:pointer; opacity:0.7; font-size:12px">debug log</summary>
|
||||
<div id="log"></div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- webrtc-adapter is required by janus.js (cross-browser WebRTC API
|
||||
normalizer). The upstream siptest pulls it from CDN; we do the same.
|
||||
Without it Janus.init() silently fails. -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/webrtc-adapter@9.0.1/out/adapter.min.js"></script>
|
||||
<script src="janus.js"></script>
|
||||
<script>
|
||||
// ============================================================================
|
||||
// Configuration (hard-coded; this is a dev demo)
|
||||
// ============================================================================
|
||||
const JANUS_SERVER = location.origin + "/janus"; // nginx proxies to janus
|
||||
const SIP_PROXY = "sip:sip:5060"; // docker service name
|
||||
const SIP_IDENTITY = "sip:demo@example.invalid"; // no registrar, guest
|
||||
const SIP_PEER = "sip:phone@sip:5060"; // user-part irrelevant
|
||||
// (Direct+PIN rule)
|
||||
const DISPLAY_NAME = "test-user";
|
||||
const ICE_SERVERS = []; // ICE-TCP via janus.jcfg
|
||||
|
||||
// ============================================================================
|
||||
// UI helpers
|
||||
// ============================================================================
|
||||
const $ = id => document.getElementById(id);
|
||||
function setStatus(text) { $("status").textContent = text; log(text); }
|
||||
function log(msg, isErr = false) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "log-line" + (isErr ? " log-err" : "");
|
||||
line.textContent = "[" + new Date().toLocaleTimeString() + "] " + msg;
|
||||
$("log").appendChild(line);
|
||||
$("log").scrollTop = $("log").scrollHeight;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Janus state
|
||||
// ============================================================================
|
||||
let janus = null;
|
||||
let sipcall = null;
|
||||
let inCall = false;
|
||||
|
||||
// ============================================================================
|
||||
// Init: create Janus → session → SIP handle → register → call
|
||||
// ============================================================================
|
||||
Janus.init({
|
||||
debug: false,
|
||||
callback: () => {
|
||||
if (!Janus.isWebrtcSupported()) {
|
||||
setStatus("WebRTC not supported in this browser.");
|
||||
return;
|
||||
}
|
||||
createSession();
|
||||
}
|
||||
});
|
||||
|
||||
function createSession() {
|
||||
setStatus("Connecting to Janus…");
|
||||
janus = new Janus({
|
||||
server: JANUS_SERVER,
|
||||
iceServers: ICE_SERVERS,
|
||||
success: () => {
|
||||
setStatus("Connected. Attaching SIP plugin…");
|
||||
attachSip();
|
||||
},
|
||||
error: (err) => setStatus("Janus error: " + err),
|
||||
destroyed: () => setStatus("Janus session destroyed."),
|
||||
});
|
||||
}
|
||||
|
||||
function attachSip() {
|
||||
janus.attach({
|
||||
plugin: "janus.plugin.sip",
|
||||
opaqueId: "sip-demo-" + Janus.randomString(8),
|
||||
success: (handle) => {
|
||||
sipcall = handle;
|
||||
window.sipcall = handle; // for ad-hoc console debugging
|
||||
registerAsGuest();
|
||||
},
|
||||
error: (err) => setStatus("Plugin attach error: " + err),
|
||||
onmessage: handleMessage,
|
||||
onlocaltrack: (track, on) => {
|
||||
// We don't render local preview — Meet tab is already showing it.
|
||||
},
|
||||
onremotetrack: (track, mid, on, meta) => {
|
||||
if (!on) return;
|
||||
const stream = new MediaStream([track]);
|
||||
if (track.kind === "audio") {
|
||||
$("remoteAudio").srcObject = stream;
|
||||
} else if (track.kind === "video") {
|
||||
$("remoteVideo").srcObject = stream;
|
||||
$("noVideo").style.display = "none";
|
||||
}
|
||||
},
|
||||
oncleanup: () => { setStatus("Call cleaned up."); endCallUi(); },
|
||||
});
|
||||
}
|
||||
|
||||
function registerAsGuest() {
|
||||
setStatus("Registering as guest…");
|
||||
sipcall.send({
|
||||
message: {
|
||||
request: "register",
|
||||
type: "guest",
|
||||
proxy: SIP_PROXY,
|
||||
username: SIP_IDENTITY,
|
||||
display_name: DISPLAY_NAME,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function placeCall() {
|
||||
setStatus("Calling " + SIP_PEER + "…");
|
||||
const tracks = [
|
||||
{ type: "audio", capture: true, recv: true },
|
||||
{ type: "video", capture: true, recv: true },
|
||||
];
|
||||
sipcall.createOffer({
|
||||
tracks: tracks,
|
||||
success: (jsep) => {
|
||||
sipcall.send({
|
||||
message: { request: "call", uri: SIP_PEER, autoaccept_reinvites: false },
|
||||
jsep: jsep,
|
||||
});
|
||||
},
|
||||
error: (err) => setStatus("createOffer failed: " + err),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Plugin event router
|
||||
// ============================================================================
|
||||
function handleMessage(msg, jsep) {
|
||||
const result = msg && msg.result;
|
||||
const event = result && result.event;
|
||||
if (event) log("event: " + event);
|
||||
|
||||
if (event === "registered") {
|
||||
setStatus("Registered. Placing call…");
|
||||
// Small delay to settle the registration
|
||||
setTimeout(placeCall, 100);
|
||||
}
|
||||
else if (event === "calling") {
|
||||
setStatus("Calling…");
|
||||
}
|
||||
else if (event === "accepted") {
|
||||
setStatus("Call accepted. You should hear the IVR — enter PIN on the dialpad.");
|
||||
startCallUi();
|
||||
if (jsep) sipcall.handleRemoteJsep({ jsep: jsep });
|
||||
}
|
||||
else if (event === "hangup") {
|
||||
setStatus("Call ended: " + (result.reason || result.code || ""));
|
||||
endCallUi();
|
||||
}
|
||||
else if (event === "missed_call") {
|
||||
setStatus("Missed call.");
|
||||
endCallUi();
|
||||
}
|
||||
else if (event === "registration_failed") {
|
||||
setStatus("Registration failed: " + result.code + " " + result.reason, true);
|
||||
}
|
||||
else if (msg && msg.error) {
|
||||
setStatus("Plugin error: " + msg.error, true);
|
||||
}
|
||||
|
||||
if (jsep && event !== "accepted") {
|
||||
sipcall.handleRemoteJsep({ jsep: jsep });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// In-call UI: mute, hangup, dialpad, PIN entry
|
||||
// ============================================================================
|
||||
function startCallUi() {
|
||||
inCall = true;
|
||||
$("pin-row").style.display = "flex";
|
||||
$("pin-row").classList.remove("disabled");
|
||||
$("dialpad").style.display = "grid";
|
||||
$("dialpad").classList.remove("disabled");
|
||||
$("hangupBtn").disabled = false;
|
||||
$("rejoinBtn").style.display = "none";
|
||||
// Auto-mute mic so two tabs on the same Mac don't feedback-howl.
|
||||
if (sipcall.isAudioMuted && !sipcall.isAudioMuted()) {
|
||||
sipcall.muteAudio();
|
||||
}
|
||||
updateMuteButton();
|
||||
updateCameraButton();
|
||||
updateSpeakerButton();
|
||||
}
|
||||
|
||||
function endCallUi() {
|
||||
inCall = false;
|
||||
$("pin-row").style.display = "none";
|
||||
$("pin-row").classList.remove("disabled");
|
||||
$("dialpad").style.display = "none";
|
||||
$("dialpad").classList.remove("disabled");
|
||||
$("hangupBtn").disabled = true;
|
||||
$("rejoinBtn").style.display = "inline-block";
|
||||
$("remoteVideo").srcObject = null;
|
||||
$("remoteAudio").srcObject = null;
|
||||
$("noVideo").style.display = "flex";
|
||||
}
|
||||
|
||||
function updateMuteButton() {
|
||||
const b = $("muteBtn");
|
||||
if (!sipcall || !sipcall.isAudioMuted) return;
|
||||
if (sipcall.isAudioMuted()) {
|
||||
b.textContent = "🔇 Mic muted";
|
||||
b.className = "danger";
|
||||
} else {
|
||||
b.textContent = "🎤 Mic on";
|
||||
b.className = "success";
|
||||
}
|
||||
}
|
||||
|
||||
function updateCameraButton() {
|
||||
const b = $("cameraBtn");
|
||||
if (!sipcall || !sipcall.isVideoMuted) return;
|
||||
if (sipcall.isVideoMuted()) {
|
||||
b.textContent = "📷 Camera off";
|
||||
b.className = "danger";
|
||||
} else {
|
||||
b.textContent = "📷 Camera on";
|
||||
b.className = "success";
|
||||
}
|
||||
}
|
||||
|
||||
function updateSpeakerButton() {
|
||||
const b = $("speakerBtn");
|
||||
const audio = $("remoteAudio");
|
||||
if (audio.muted) {
|
||||
b.textContent = "🔇 Speaker off";
|
||||
b.className = "danger";
|
||||
} else {
|
||||
b.textContent = "🔊 Speaker on";
|
||||
b.className = "success";
|
||||
}
|
||||
}
|
||||
|
||||
$("muteBtn").addEventListener("click", () => {
|
||||
if (!sipcall) return;
|
||||
if (sipcall.isAudioMuted()) sipcall.unmuteAudio();
|
||||
else sipcall.muteAudio();
|
||||
setTimeout(updateMuteButton, 50);
|
||||
});
|
||||
|
||||
$("cameraBtn").addEventListener("click", () => {
|
||||
if (!sipcall || !sipcall.muteVideo) return;
|
||||
if (sipcall.isVideoMuted()) sipcall.unmuteVideo();
|
||||
else sipcall.muteVideo();
|
||||
setTimeout(updateCameraButton, 50);
|
||||
});
|
||||
|
||||
$("speakerBtn").addEventListener("click", () => {
|
||||
const audio = $("remoteAudio");
|
||||
audio.muted = !audio.muted;
|
||||
updateSpeakerButton();
|
||||
});
|
||||
|
||||
$("hangupBtn").addEventListener("click", () => {
|
||||
if (!sipcall) return;
|
||||
sipcall.send({ message: { request: "hangup" } });
|
||||
sipcall.hangup();
|
||||
});
|
||||
|
||||
$("rejoinBtn").addEventListener("click", () => location.reload());
|
||||
|
||||
// ============================================================================
|
||||
// DTMF — try the in-band RTP-event path first (RFC 4733 via the handle's
|
||||
// dtmf() method), which is what livekit-sip's IVR actually listens for.
|
||||
// Fall back to SIP INFO if RTP DTMF isn't available (older Janus, no
|
||||
// telephone-event in SDP, etc.).
|
||||
// ============================================================================
|
||||
function sendDtmf(digit) {
|
||||
if (!sipcall || !inCall) return;
|
||||
log("DTMF → " + digit);
|
||||
let sent = false;
|
||||
try {
|
||||
if (typeof sipcall.dtmf === "function") {
|
||||
sipcall.dtmf({
|
||||
dtmf: { tones: String(digit), duration: 200, gap: 70 },
|
||||
success: () => {},
|
||||
error: (err) => log("dtmf() error: " + err, true),
|
||||
});
|
||||
sent = true;
|
||||
}
|
||||
} catch (e) {
|
||||
log("dtmf() threw: " + e, true);
|
||||
}
|
||||
if (!sent) {
|
||||
// Fallback: SIP INFO method
|
||||
sipcall.send({
|
||||
message: { request: "dtmf_info", digit: String(digit) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll(".dialpad button").forEach(b => {
|
||||
b.addEventListener("click", () => sendDtmf(b.dataset.d));
|
||||
});
|
||||
|
||||
$("sendPin").addEventListener("click", () => {
|
||||
const pin = $("pin").value.replace(/[^\d#*]/g, "");
|
||||
if (!pin) return;
|
||||
const tones = (pin.endsWith("#") ? pin : pin + "#");
|
||||
log("DTMF batch → " + tones);
|
||||
// Send all tones in ONE call. Per-digit looping with setTimeout races
|
||||
// the WebRTC DTMF sender's internal queue and drops digits (we lost
|
||||
// the trailing "0" before "#" doing it digit-by-digit). The browser's
|
||||
// RTCDTMFSender paces them itself given duration/gap.
|
||||
try {
|
||||
sipcall.dtmf({
|
||||
dtmf: { tones: tones, duration: 400, gap: 100 },
|
||||
success: () => log("DTMF batch sent"),
|
||||
error: (err) => log("dtmf() error: " + err, true),
|
||||
});
|
||||
} catch (e) {
|
||||
log("dtmf() threw, falling back to SIP INFO per digit: " + e, true);
|
||||
for (const ch of tones) {
|
||||
sipcall.send({ message: { request: "dtmf_info", digit: String(ch) } });
|
||||
}
|
||||
}
|
||||
$("pin").value = "";
|
||||
// PIN + dialpad are no longer needed once you've submitted. Gateway
|
||||
// either bridges you in (correct PIN) or hangs up with wrong-pin
|
||||
// (the hangup event re-resets everything via endCallUi).
|
||||
$("pin-row").classList.add("disabled");
|
||||
$("dialpad").classList.add("disabled");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,10 +61,11 @@ services:
|
||||
`docker compose up -d`
|
||||
```
|
||||
|
||||
Your keycloak instance is now available on https://id.yourdomain.tld
|
||||
Your keycloak instance is now available on https://doc.yourdomain.tld
|
||||
|
||||
> [!CAUTION]
|
||||
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
|
||||
```
|
||||
|
||||
## Creating an OIDC Client for Meet Application
|
||||
|
||||
@@ -75,7 +76,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
|
||||
3. Enter the name of the realm - `meet`.
|
||||
4. Click "Create".
|
||||
|
||||
### Step 2: Create a New Client
|
||||
#### Step 2: Create a New Client
|
||||
|
||||
1. Navigate to the "Clients" tab.
|
||||
2. Click on the "Create client" button.
|
||||
@@ -85,7 +86,7 @@ Your keycloak instance is now available on https://id.yourdomain.tld
|
||||
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
|
||||
1. Click "Save".
|
||||
|
||||
### Step 3: Get Client Credentials
|
||||
#### Step 3: Get Client Credentials
|
||||
|
||||
1. Go to the "Credentials" tab.
|
||||
2. Copy the client ID (`meet` in this example) and the client secret.
|
||||
|
||||
@@ -190,7 +190,6 @@ paths:
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
/rooms/:
|
||||
post:
|
||||
tags:
|
||||
- Rooms
|
||||
|
||||
@@ -113,7 +113,6 @@ paths:
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
/rooms/:
|
||||
post:
|
||||
tags:
|
||||
- Rooms
|
||||
|
||||
@@ -27,7 +27,7 @@ AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
MEDIA_BASE_URL=http://localhost:3000
|
||||
MEDIA_BASE_URL=http://localhost:8083
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
# OIDC
|
||||
|
||||
@@ -36,12 +36,6 @@
|
||||
"matchPackageNames": ["django"],
|
||||
"allowedVersions": "<6.0.0"
|
||||
},
|
||||
{
|
||||
"groupName": "allowed brevo versions",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["brevo-python"],
|
||||
"allowedVersions": "<3.0.0"
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
|
||||
@@ -4,6 +4,8 @@ FROM python:3.13-slim AS base
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
"openssl=3.5.4-1~deb13u2" \
|
||||
"libssl3t64=3.5.4-1~deb13u2" \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.12.0"
|
||||
version = "1.10.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.4.5",
|
||||
"livekit-plugins-deepgram==1.4.5",
|
||||
"livekit-plugins-silero==1.4.5",
|
||||
"livekit-agents==1.3.10",
|
||||
"livekit-plugins-deepgram==1.3.10",
|
||||
"livekit-plugins-silero==1.3.10",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"python-dotenv==1.2.2",
|
||||
"python-dotenv==1.2.1",
|
||||
"protobuf==6.33.5"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.15.6",
|
||||
"ruff==0.14.4",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -73,8 +73,5 @@ def get_frontend_configuration(request):
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
},
|
||||
}
|
||||
frontend_configuration["encryption"] = {
|
||||
"enabled": settings.ENCRYPTION_ENABLED,
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -30,17 +30,8 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"id",
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"timezone",
|
||||
"language",
|
||||
"default_encryption",
|
||||
]
|
||||
read_only_fields = ["id", "sub", "email", "full_name", "short_name"]
|
||||
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class UserLightSerializer(serializers.ModelSerializer):
|
||||
@@ -83,7 +74,6 @@ class ResourceAccessSerializerMixin:
|
||||
raise PermissionDenied(
|
||||
"Only owners of a room can assign other users as owners."
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def validate_resource(self, resource):
|
||||
@@ -138,45 +128,9 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"configuration",
|
||||
"access_level",
|
||||
"pin_code",
|
||||
"is_encrypted",
|
||||
"encryption_paused",
|
||||
]
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def validate_is_encrypted(self, value):
|
||||
"""is_encrypted is set at creation and is part of the link's identity
|
||||
(copy-link always carries the hash for an encrypted room). Mid-call
|
||||
toggling is done via encryption_paused, not by mutating this flag."""
|
||||
instance = self.instance
|
||||
if instance and instance.is_encrypted != value:
|
||||
raise serializers.ValidationError(
|
||||
"Encryption mode cannot be changed after room creation. "
|
||||
"Use encryption_paused to temporarily suspend encryption."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate_encryption_paused(self, value):
|
||||
"""encryption_paused only makes sense on encrypted rooms. Allow either
|
||||
direction (True ↔ False) post-creation: admins suspend E2EE so a
|
||||
SIP/device caller can join, then resume once they hang up."""
|
||||
is_encrypted = (
|
||||
self.instance.is_encrypted
|
||||
if self.instance is not None
|
||||
else self.initial_data.get("is_encrypted", False)
|
||||
)
|
||||
if value and not is_encrypted:
|
||||
raise serializers.ValidationError(
|
||||
"Cannot pause encryption on a non-encrypted room."
|
||||
)
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Add users only for administrator users.
|
||||
@@ -218,7 +172,6 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
if should_access_room:
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
|
||||
output["livekit"] = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
@@ -312,7 +265,7 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True, allow_blank=True)
|
||||
username = serializers.CharField(required=True)
|
||||
|
||||
|
||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||
@@ -350,9 +303,6 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
||||
|
||||
|
||||
class ParticipantPermission(BaseModel):
|
||||
"""Mirror the LiveKit ParticipantPermission protobuf.
|
||||
|
||||
@@ -363,7 +313,9 @@ class ParticipantPermission(BaseModel):
|
||||
can_subscribe: bool | None = None
|
||||
can_publish: bool | None = None
|
||||
can_publish_data: bool | None = None
|
||||
can_publish_sources: list[TrackSource] = Field(default_factory=list)
|
||||
can_publish_sources: list[int] = Field(
|
||||
default_factory=list
|
||||
) # TrackSource enum values
|
||||
hidden: bool | None = None
|
||||
recorder: bool | None = None
|
||||
can_update_metadata: bool | None = None
|
||||
@@ -414,6 +366,14 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
f"Setting the following participant permissions is not allowed: "
|
||||
f"{', '.join(suspicious_fields)}."
|
||||
)
|
||||
if permission.can_subscribe_metrics is not None:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"permission": {
|
||||
"can_subscribe_metrics": "This permission is not implemented."
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return permission
|
||||
|
||||
|
||||
@@ -73,3 +73,9 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class SessionExchangeAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle anonymous requests to the session exchange endpoint."""
|
||||
|
||||
scope = "session_exchange"
|
||||
|
||||
@@ -281,14 +281,6 @@ class RoomViewSet(
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created room."""
|
||||
is_encrypted = serializer.validated_data.get("is_encrypted", False)
|
||||
|
||||
# Block encrypted room creation if encryption is not enabled on this instance
|
||||
if is_encrypted and not settings.ENCRYPTION_ENABLED:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"is_encrypted": "Encryption is not enabled on this server."}
|
||||
)
|
||||
|
||||
room = serializer.save()
|
||||
models.ResourceAccess.objects.create(
|
||||
resource=room,
|
||||
@@ -314,16 +306,8 @@ class RoomViewSet(
|
||||
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
print(
|
||||
"[start-recording] body=",
|
||||
request.data,
|
||||
"errors=",
|
||||
serializer.errors,
|
||||
flush=True,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid request.", "errors": serializer.errors},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
@@ -412,14 +396,12 @@ class RoomViewSet(
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
room = self.get_object()
|
||||
validated_data = serializer.validated_data
|
||||
|
||||
lobby_service = LobbyService()
|
||||
|
||||
participant, livekit = lobby_service.request_entry(
|
||||
room=room,
|
||||
request=request,
|
||||
**validated_data,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||
lobby_service.prepare_response(response, participant.id)
|
||||
@@ -482,7 +464,6 @@ class RoomViewSet(
|
||||
lobby_service = LobbyService()
|
||||
|
||||
participants = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
return drf_response.Response({"participants": participants})
|
||||
|
||||
@decorators.action(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""API endpoint for exchanging a one-time code for a session ID."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.decorators import api_view, permission_classes, throttle_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
from core.api.throttling import SessionExchangeAnonRateThrottle
|
||||
|
||||
from .views import EXCHANGE_CODE_PREFIX
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionExchangeSerializer(serializers.Serializer):
|
||||
"""Validates the exchange code request."""
|
||||
|
||||
code = serializers.CharField(max_length=64, min_length=16)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
@throttle_classes([SessionExchangeAnonRateThrottle])
|
||||
def session_exchange(
|
||||
request,
|
||||
): # NOSONAR (S3752) POST-only, AllowAny is intentional: single-use code with 30s TTL and rate limiting
|
||||
"""Exchange a one-time code for a session ID.
|
||||
|
||||
The code was generated during the OIDC callback and stored in cache
|
||||
with a short TTL. This endpoint retrieves the session ID, deletes the
|
||||
code (single-use), and returns the session ID to the native app.
|
||||
"""
|
||||
serializer = SessionExchangeSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
code = serializer.validated_data["code"]
|
||||
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
|
||||
|
||||
session_key = cache.get(cache_key)
|
||||
if session_key is None:
|
||||
logger.warning(
|
||||
"Session exchange failed: invalid or expired code from %s",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
return Response(
|
||||
{"detail": "Invalid or expired code."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Delete immediately — single use
|
||||
cache.delete(cache_key)
|
||||
|
||||
cookie_name = getattr(settings, "SESSION_COOKIE_NAME", "sessionid")
|
||||
logger.info("Session exchange successful from %s", request.META.get("REMOTE_ADDR"))
|
||||
|
||||
return Response({cookie_name: session_key})
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Custom OIDC authentication views for native app support.
|
||||
|
||||
When a native app (iOS, Android, Desktop) initiates OIDC login, it sets
|
||||
`returnTo` to a custom URL scheme (e.g. `visio://auth-callback`). After
|
||||
the OIDC flow completes, Django sets the session cookie and redirects to
|
||||
that URL. However, native apps cannot read browser cookies — they need
|
||||
the session ID passed explicitly.
|
||||
|
||||
Instead of exposing the session ID directly in the redirect URL, this
|
||||
module generates a short-lived, single-use exchange code. The native app
|
||||
then exchanges this code for the session ID via a dedicated API endpoint.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
from lasuite.oidc_login.views import (
|
||||
OIDCAuthenticationCallbackView as BaseCallbackView,
|
||||
OIDCAuthenticationRequestView as BaseRequestView,
|
||||
)
|
||||
|
||||
# Cache key prefix and TTL for exchange codes
|
||||
EXCHANGE_CODE_PREFIX = "auth_exchange:"
|
||||
EXCHANGE_CODE_TTL = 30 # seconds
|
||||
|
||||
|
||||
class NativeAppRedirect(HttpResponseRedirect):
|
||||
"""HttpResponseRedirect subclass that allows native app custom URL schemes.
|
||||
|
||||
Django's HttpResponseRedirect only allows http, https, and ftp schemes.
|
||||
Native apps use custom schemes (e.g. visio://) for deep links, which
|
||||
Django rejects with DisallowedRedirect. This subclass extends
|
||||
allowed_schemes with the configured native app schemes.
|
||||
"""
|
||||
|
||||
allowed_schemes = HttpResponseRedirect.allowed_schemes + list(
|
||||
getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(BaseRequestView):
|
||||
"""Custom authenticate view that preserves native app returnTo in session.
|
||||
|
||||
mozilla-django-oidc's get_next_url() rejects custom URL schemes
|
||||
(e.g. visio://auth-callback) because url_has_allowed_host_and_scheme()
|
||||
only allows http/https. We intercept the returnTo parameter and store
|
||||
it directly in the session for whitelisted schemes, bypassing the
|
||||
safety check (which is not relevant for native app deep links).
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
redirect_field = getattr(settings, "OIDC_REDIRECT_FIELD_NAME", "returnTo")
|
||||
return_to = request.GET.get(redirect_field, "")
|
||||
parsed = urlparse(return_to)
|
||||
allowed_schemes = getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
|
||||
|
||||
response = super().get(request)
|
||||
|
||||
# Override oidc_login_next AFTER super() which set it to None
|
||||
# via get_next_url() rejecting the custom scheme.
|
||||
if parsed.scheme in allowed_schemes:
|
||||
request.session["oidc_login_next"] = return_to
|
||||
request.session.save()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(BaseCallbackView):
|
||||
"""Callback view that generates an exchange code for native app deep links."""
|
||||
|
||||
def login_success(self):
|
||||
"""After successful login, append exchange code for whitelisted scheme redirects."""
|
||||
# Temporarily remove native redirect from session to prevent
|
||||
# super().login_success() from raising DisallowedRedirect when
|
||||
# it tries HttpResponseRedirect with a custom scheme.
|
||||
native_redirect = self.request.session.pop("oidc_login_next", None)
|
||||
allowed_schemes = getattr(settings, "NATIVE_APP_REDIRECT_SCHEMES", [])
|
||||
parsed = urlparse(native_redirect or "")
|
||||
|
||||
if native_redirect and parsed.scheme in allowed_schemes:
|
||||
# Let super() redirect to the default URL (homepage)
|
||||
super().login_success()
|
||||
|
||||
# Generate a short-lived, single-use exchange code
|
||||
exchange_code = uuid.uuid4().hex
|
||||
session_key = self.request.session.session_key
|
||||
cache.set(
|
||||
f"{EXCHANGE_CODE_PREFIX}{exchange_code}",
|
||||
session_key,
|
||||
EXCHANGE_CODE_TTL,
|
||||
)
|
||||
|
||||
separator = "&" if parsed.query else "?"
|
||||
new_url = (
|
||||
f"{native_redirect}{separator}{urlencode({'code': exchange_code})}"
|
||||
)
|
||||
return NativeAppRedirect(new_url)
|
||||
|
||||
return super().login_success()
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Add Room.is_encrypted and User.default_encryption.
|
||||
|
||||
`is_encrypted` is a boolean for now because passphrase-in-URL is the only
|
||||
supported mode. A future migration may turn it into a CharField/enum if a
|
||||
local-keys (vault) mode is reintroduced.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0018_rename_active_application_is_active"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="room",
|
||||
name="is_encrypted",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether end-to-end encryption is enabled for this room.",
|
||||
verbose_name="Encryption enabled",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="default_encryption",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether new meetings created by this user are end-to-end encrypted by default.",
|
||||
verbose_name="Default to end-to-end encryption",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.12 on 2026-05-12 08:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0019_room_is_encrypted_user_default_encryption'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='encryption_paused',
|
||||
field=models.BooleanField(default=False, help_text='Temporarily suspend E2EE so external devices can join.', verbose_name='Encryption paused'),
|
||||
),
|
||||
]
|
||||
@@ -200,14 +200,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"Unselect this instead of deleting accounts."
|
||||
),
|
||||
)
|
||||
default_encryption = models.BooleanField(
|
||||
_("Default to end-to-end encryption"),
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Whether new meetings created by this user are "
|
||||
"end-to-end encrypted by default."
|
||||
),
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
|
||||
@@ -396,25 +388,6 @@ class Room(Resource):
|
||||
choices=RoomAccessLevel.choices,
|
||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
)
|
||||
# Boolean for now: today the only encryption mode is the passphrase-in-URL
|
||||
# one. If a follow-up adds a stronger "local-keys" mode (private keys held
|
||||
# in a vault iframe), this can grow into a CharField with choices like
|
||||
# `none / passphrase / local_keys`. Set at creation; never mutated after —
|
||||
# changing it would change the link's semantics (copy-link / hash carry).
|
||||
is_encrypted = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Encryption enabled"),
|
||||
help_text=_("Whether end-to-end encryption is enabled for this room."),
|
||||
)
|
||||
# Mid-call admin override: when True on an `is_encrypted` room, the active
|
||||
# encryption is suspended so a phone/SIP/device caller can join in plaintext.
|
||||
# The link still carries the hash; toggling back to False resumes E2EE.
|
||||
# Always False on non-encrypted rooms (enforced by the serializer).
|
||||
encryption_paused = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Encryption paused"),
|
||||
help_text=_("Temporarily suspend E2EE so external devices can join."),
|
||||
)
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
@@ -440,44 +413,13 @@ class Room(Resource):
|
||||
return capfirst(self.name)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms."""
|
||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
|
||||
previous = None
|
||||
if self.pk:
|
||||
try:
|
||||
previous = Room.objects.only(
|
||||
"is_encrypted", "encryption_paused"
|
||||
).get(pk=self.pk)
|
||||
except Room.DoesNotExist:
|
||||
previous = None
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Sync encryption state to LiveKit room metadata so the SIP gateway can
|
||||
# decide between bridge mode and placeholder mode. Best-effort: if no
|
||||
# LiveKit room exists yet, the call is a no-op.
|
||||
encryption_changed = previous is None or (
|
||||
previous.is_encrypted != self.is_encrypted
|
||||
or previous.encryption_paused != self.encryption_paused
|
||||
)
|
||||
if encryption_changed:
|
||||
try:
|
||||
from core import utils as core_utils # local import: avoid cycle
|
||||
|
||||
core_utils.update_room_metadata(
|
||||
room_name=str(self.pk),
|
||||
metadata={
|
||||
"is_encrypted": bool(self.is_encrypted),
|
||||
"encryption_paused": bool(self.encryption_paused),
|
||||
},
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Metadata sync is advisory — never break Room.save() over it.
|
||||
pass
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
"""
|
||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||
|
||||
@@ -4,7 +4,7 @@ import smtplib
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -45,18 +45,15 @@ class InvitationService:
|
||||
)
|
||||
) # Force translation
|
||||
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=msg_plain,
|
||||
from_email=settings.EMAIL_FROM,
|
||||
to=[],
|
||||
bcc=emails,
|
||||
)
|
||||
|
||||
email.attach_alternative(msg_html, "text/html")
|
||||
|
||||
try:
|
||||
email.send()
|
||||
send_mail(
|
||||
subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error("invitations were not sent: %s", e)
|
||||
logger.error("invitation to %s was not sent: %s", emails, e)
|
||||
raise InvitationError("Could not send invitation") from e
|
||||
|
||||
@@ -18,10 +18,6 @@ from core.recording.services.recording_events import (
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .participants_management import (
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -189,72 +185,6 @@ class LiveKitEventsService:
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
|
||||
def _handle_participant_joined(self, data):
|
||||
"""Handle 'participant_joined' event.
|
||||
|
||||
When a SIP/phone participant joins an end-to-end encrypted room they
|
||||
cannot decrypt anything. We:
|
||||
1. Send an in-band notification so admins see a snackbar with an
|
||||
"Open settings" CTA.
|
||||
2. Leave the participant connected — the gateway is responsible for
|
||||
holding them on a placeholder prompt loop ("this meeting is
|
||||
encrypted, ask the host to disable it") until either the admin
|
||||
turns encryption off (we update LiveKit room metadata, gateway
|
||||
reacts) or the user hangs up.
|
||||
|
||||
See README "Phone / SIP participants" for the full flow.
|
||||
"""
|
||||
|
||||
participant = getattr(data, "participant", None)
|
||||
if participant is None:
|
||||
return
|
||||
|
||||
# LiveKit ParticipantInfo.Kind: 0 = STANDARD, 1 = INGRESS, 2 = EGRESS,
|
||||
# 3 = SIP, 4 = AGENT. We treat both SIP and INGRESS as "external
|
||||
# device that can't run our E2EE code".
|
||||
kind = getattr(participant, "kind", 0)
|
||||
is_external_device = kind in (1, 3)
|
||||
if not is_external_device:
|
||||
return
|
||||
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
try:
|
||||
room = models.Room.objects.get(id=room_id)
|
||||
except models.Room.DoesNotExist:
|
||||
return
|
||||
|
||||
# Live encryption: is_encrypted set AND not currently paused.
|
||||
# If the admin has already paused encryption, the gateway will bridge
|
||||
# the call normally — no need to surface a blocked notification.
|
||||
if not room.is_encrypted or room.encryption_paused:
|
||||
return
|
||||
|
||||
# 1. Broadcast a system notice — frontends decode this on the
|
||||
# "encryption-state" or notifications channel and show a snackbar.
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(room_id),
|
||||
notification_data={
|
||||
"type": "external_device_blocked",
|
||||
"participant_identity": participant.identity,
|
||||
"participant_name": participant.name or participant.identity,
|
||||
},
|
||||
)
|
||||
except utils.NotificationError:
|
||||
logger.exception(
|
||||
"Failed to notify room about blocked external device"
|
||||
)
|
||||
|
||||
# No eject. The gateway reads LiveKit room metadata
|
||||
# (`{is_encrypted, encryption_paused}` — see `Room.save()`) and stays
|
||||
# in placeholder-prompt mode for the SIP leg as long as encryption is
|
||||
# live. When the admin sets encryption_paused=true, the gateway
|
||||
# transitions to a normal bridge without dropping the call.
|
||||
|
||||
def _handle_room_started(self, data):
|
||||
"""Handle 'room_started' event."""
|
||||
|
||||
@@ -272,22 +202,6 @@ class LiveKitEventsService:
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
|
||||
# Now that the LiveKit room object exists, push the encryption flags
|
||||
# into its metadata so the SIP gateway can read them as soon as a SIP
|
||||
# caller joins. Room.save() also tries this on every change, but at
|
||||
# *creation* time the LiveKit room didn't exist yet — this is the
|
||||
# first reliable opportunity.
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
str(room_id),
|
||||
{
|
||||
"is_encrypted": bool(room.is_encrypted),
|
||||
"encryption_paused": bool(room.encryption_paused),
|
||||
},
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
logger.exception("Failed to seed encryption metadata: %s", e)
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
try:
|
||||
self.telephony_service.create_dispatch_rule(room)
|
||||
|
||||
@@ -46,18 +46,14 @@ class LobbyParticipant:
|
||||
username: str
|
||||
color: str
|
||||
id: str
|
||||
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
|
||||
# they can decide whether to accept self-declared identities.
|
||||
is_authenticated: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, object]:
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
"""Serialize the participant object to a dict representation."""
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"username": self.username,
|
||||
"id": self.id,
|
||||
"color": self.color,
|
||||
"is_authenticated": self.is_authenticated,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -72,7 +68,6 @@ class LobbyParticipant:
|
||||
username=data["username"],
|
||||
id=data["id"],
|
||||
color=data["color"],
|
||||
is_authenticated=bool(data.get("is_authenticated", False)),
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.exception("Error creating Participant from dict:")
|
||||
@@ -104,7 +99,7 @@ class LobbyService:
|
||||
key=settings.LOBBY_COOKIE_NAME,
|
||||
value=participant_id,
|
||||
httponly=True,
|
||||
secure=not settings.DEBUG,
|
||||
secure=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
@@ -157,7 +152,6 @@ class LobbyService:
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
@@ -176,17 +170,13 @@ class LobbyService:
|
||||
livekit_config = None
|
||||
|
||||
if participant is None:
|
||||
participant = self.enter(
|
||||
room.id,
|
||||
participant_id,
|
||||
username,
|
||||
is_authenticated=request.user.is_authenticated,
|
||||
)
|
||||
participant = self.enter(room.id, participant_id, username)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||
self.refresh_waiting_status(room.id, participant_id)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# wrongly named, contains access token to join a room
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
@@ -211,11 +201,7 @@ class LobbyService:
|
||||
)
|
||||
|
||||
def enter(
|
||||
self,
|
||||
room_id: UUID,
|
||||
participant_id: str,
|
||||
username: str,
|
||||
is_authenticated: bool = False,
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby.
|
||||
|
||||
@@ -230,7 +216,6 @@ class LobbyService:
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=color,
|
||||
is_authenticated=is_authenticated,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Tests for the session exchange API endpoint and OIDC callback view.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.authentication.views import EXCHANGE_CODE_PREFIX, EXCHANGE_CODE_TTL
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_throttle_cache():
|
||||
"""Clear cache before each test to reset throttle counters."""
|
||||
cache.clear()
|
||||
|
||||
|
||||
def test_session_exchange_valid_code():
|
||||
"""A valid exchange code should return the session ID and be consumed."""
|
||||
code = uuid.uuid4().hex
|
||||
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
|
||||
cache.set(cache_key, "test-session-key-123", EXCHANGE_CODE_TTL)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": code},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "test-session-key-123" in data.values()
|
||||
|
||||
# Code should be consumed (single-use)
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
|
||||
def test_session_exchange_invalid_code():
|
||||
"""An invalid/unknown code should return 400."""
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": uuid.uuid4().hex},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Invalid or expired code."
|
||||
|
||||
|
||||
def test_session_exchange_expired_code():
|
||||
"""An expired code should return 400."""
|
||||
code = uuid.uuid4().hex
|
||||
cache_key = f"{EXCHANGE_CODE_PREFIX}{code}"
|
||||
# Set with 0 TTL to simulate expiration
|
||||
cache.set(cache_key, "expired-session", 0)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": code},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_session_exchange_code_too_short():
|
||||
"""A code that's too short should be rejected by validation."""
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": "short"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_session_exchange_missing_code():
|
||||
"""Missing code field should be rejected."""
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_session_exchange_replay_attack():
|
||||
"""Using the same code twice should fail the second time."""
|
||||
code = uuid.uuid4().hex
|
||||
cache.set(f"{EXCHANGE_CODE_PREFIX}{code}", "session-123", EXCHANGE_CODE_TTL)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
# First use succeeds
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": code},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Second use fails
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": code},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_session_exchange_returns_correct_cookie_name(settings):
|
||||
"""The response key should match SESSION_COOKIE_NAME from settings."""
|
||||
settings.SESSION_COOKIE_NAME = "meet_sessionid"
|
||||
code = uuid.uuid4().hex
|
||||
cache.set(f"{EXCHANGE_CODE_PREFIX}{code}", "my-session", EXCHANGE_CODE_TTL)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/api/v1.0/auth/session-exchange/",
|
||||
{"code": code},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"meet_sessionid": "my-session"}
|
||||
|
||||
|
||||
def test_session_exchange_get_not_allowed():
|
||||
"""GET method should not be allowed on the exchange endpoint."""
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/auth/session-exchange/")
|
||||
assert response.status_code == 405
|
||||
@@ -240,9 +240,10 @@ def test_api_rooms_invite_error(mock_invite_to_room):
|
||||
mock_invite_to_room.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.invitation.EmailMultiAlternatives")
|
||||
def test_api_rooms_invite_success(mock_email_class, settings):
|
||||
@mock.patch("core.services.invitation.send_mail")
|
||||
def test_api_rooms_invite_success(mock_send_mail, settings):
|
||||
"""Test privileged users should successfully send invitation emails."""
|
||||
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.EMAIL_APP_BASE_URL = "https://acme.com"
|
||||
@@ -254,6 +255,7 @@ def test_api_rooms_invite_success(mock_email_class, settings):
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
|
||||
@@ -267,38 +269,26 @@ def test_api_rooms_invite_success(mock_email_class, settings):
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "message": "invitations sent"}
|
||||
|
||||
mock_email_class.assert_called_once()
|
||||
mock_send_mail.assert_called_once()
|
||||
|
||||
# Check constructor arguments
|
||||
call_kwargs = mock_email_class.call_args[1] # EmailMultiAlternatives(**kwargs)
|
||||
subject, body, sender, recipients = mock_send_mail.call_args[0]
|
||||
|
||||
assert call_kwargs["subject"] == (
|
||||
f"Video call in progress: {user.email} is waiting for you to connect"
|
||||
)
|
||||
assert call_kwargs["from_email"] == "notifications@acme.com"
|
||||
assert call_kwargs["to"] == []
|
||||
assert sorted(call_kwargs["bcc"]) == sorted(
|
||||
["fabien@yopmail.com", "gerald@yopmail.com"]
|
||||
assert (
|
||||
subject == f"Video call in progress: {user.email} is waiting for you to connect"
|
||||
)
|
||||
|
||||
# Check plain text body
|
||||
plain_body = call_kwargs["body"]
|
||||
# Verify email contains expected content
|
||||
required_content = [
|
||||
"ACME",
|
||||
"https://acme.com/logo",
|
||||
f"https://acme.com/{room.slug}",
|
||||
f"acme.com/{room.slug}",
|
||||
"ACME", # Brand name
|
||||
"https://acme.com/logo", # Logo URL
|
||||
f"https://acme.com/{room.slug}", # Room url
|
||||
f"acme.com/{room.slug}", # Room link
|
||||
]
|
||||
for content in required_content:
|
||||
assert content in plain_body
|
||||
|
||||
# Check HTML alternative was attached
|
||||
mock_instance = mock_email_class.return_value
|
||||
mock_instance.attach_alternative.assert_called_once()
|
||||
html_body, mimetype = mock_instance.attach_alternative.call_args[0]
|
||||
assert mimetype == "text/html"
|
||||
for content in required_content:
|
||||
assert content in html_body
|
||||
assert content in body
|
||||
|
||||
# Check send was called
|
||||
mock_instance.send.assert_called_once()
|
||||
assert sender == "notifications@acme.com"
|
||||
|
||||
# Verify all owners received the email (order-independent comparison)
|
||||
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
|
||||
|
||||
@@ -130,11 +130,10 @@ def test_update_participant_success(mock_livekit_client):
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"can_publish_sources": [
|
||||
"CAMERA",
|
||||
"MICROPHONE",
|
||||
],
|
||||
1,
|
||||
2,
|
||||
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
|
||||
"can_update_metadata": True,
|
||||
"can_subscribe_metrics": True,
|
||||
},
|
||||
"name": "John Doe",
|
||||
}
|
||||
@@ -156,14 +155,8 @@ def test_update_participant_success(mock_livekit_client):
|
||||
{"can_subscribe": True},
|
||||
{"can_publish": True},
|
||||
{"can_publish_data": True},
|
||||
{
|
||||
"can_publish_sources": [
|
||||
"CAMERA",
|
||||
"MICROPHONE",
|
||||
]
|
||||
},
|
||||
{"can_publish_sources": [1, 2]},
|
||||
{"can_update_metadata": True},
|
||||
{"can_subscribe_metrics": False},
|
||||
],
|
||||
)
|
||||
def test_update_participant_permission_fields_are_optional(
|
||||
@@ -271,6 +264,35 @@ def test_update_participant_suspicious_permission_multiple(mock_suspicious):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", (False, True))
|
||||
def test_update_participant_unimplemented_can_subscribe_metrics(value):
|
||||
"""Test update participant raises 400 when can_subscribe_metrics is set."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": {
|
||||
"can_subscribe": True,
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"can_update_metadata": False,
|
||||
"can_subscribe_metrics": value,
|
||||
},
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "can_subscribe_metrics" in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_forbidden_without_access():
|
||||
"""Test update participant returns 403 when user lacks room privileges."""
|
||||
client = APIClient()
|
||||
|
||||
@@ -7,6 +7,7 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.authentication.api import session_exchange
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
@@ -40,6 +41,9 @@ urlpatterns = [
|
||||
[
|
||||
*router.urls,
|
||||
*oidc_urls,
|
||||
path(
|
||||
"auth/session-exchange/", session_exchange, name="session_exchange"
|
||||
),
|
||||
path("config/", get_frontend_configuration, name="config"),
|
||||
]
|
||||
),
|
||||
|
||||
@@ -112,12 +112,6 @@ def generate_token(
|
||||
if color is None:
|
||||
color = generate_color(identity)
|
||||
|
||||
attributes = {
|
||||
"color": color,
|
||||
"room_admin": "true" if is_admin_or_owner else "false",
|
||||
"is_authenticated": "true" if not user.is_anonymous else "false",
|
||||
}
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
@@ -126,7 +120,9 @@ def generate_token(
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_attributes(attributes)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
)
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
@@ -344,6 +344,11 @@ class Base(Configuration):
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"session_exchange": values.Value(
|
||||
default="5/minute",
|
||||
environ_name="SESSION_EXCHANGE_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||
@@ -452,19 +457,23 @@ class Base(Configuration):
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = values.Value(
|
||||
default="django.contrib.sessions.backends.cache",
|
||||
environ_name="SESSION_ENGINE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
SESSION_CACHE_ALIAS = "default"
|
||||
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
|
||||
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
|
||||
)
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
|
||||
# Custom URL schemes allowed for native app OIDC redirects.
|
||||
# Only these schemes will receive an exchange code in the callback.
|
||||
NATIVE_APP_REDIRECT_SCHEMES = values.ListValue(
|
||||
default=["visio"],
|
||||
environ_name="NATIVE_APP_REDIRECT_SCHEMES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||
)
|
||||
@@ -561,12 +570,12 @@ class Base(Configuration):
|
||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||
)
|
||||
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
||||
default=["first_name", "last_name"],
|
||||
default=["given_name", "usual_name"],
|
||||
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
||||
default="first_name",
|
||||
default="given_name",
|
||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
@@ -808,13 +817,6 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# End-to-end encryption (passphrase-in-URL-hash mode).
|
||||
# When True, users may opt in (account preference) to have their meetings
|
||||
# created as end-to-end encrypted by default.
|
||||
ENCRYPTION_ENABLED = values.BooleanValue(
|
||||
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
|
||||
)
|
||||
|
||||
# External Applications
|
||||
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
||||
40,
|
||||
|
||||
+15
-20
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.12.0"
|
||||
version = "1.10.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -24,16 +24,16 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = "MIT"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"boto3==1.42.68",
|
||||
"boto3==1.42.49",
|
||||
"Brotli==1.2.0",
|
||||
"brevo-python==1.2.0",
|
||||
"celery[redis]==5.6.2",
|
||||
"dj-database-url==3.1.2",
|
||||
"dj-database-url==3.1.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.25",
|
||||
"django-lasuite[all]==0.0.24",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==6.0.0",
|
||||
@@ -43,21 +43,21 @@ dependencies = [
|
||||
"django==5.2.12",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.3.4",
|
||||
"dockerflow==2026.1.26",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==25.1.0",
|
||||
"jsonschema==4.26.0",
|
||||
"markdown==3.10.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"psycopg[binary]==3.3.3",
|
||||
"pydantic==2.12.5",
|
||||
"PyJWT==2.12.1",
|
||||
"psycopg[binary]==3.3.2",
|
||||
"pydantic==2.12.4",
|
||||
"PyJWT==2.11.0",
|
||||
"python-frontmatter==1.1.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk==2.54.0",
|
||||
"whitenoise==6.12.0",
|
||||
"sentry-sdk==2.53.0",
|
||||
"whitenoise==6.11.0",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"livekit-api==1.1.0",
|
||||
"aiohttp==3.13.3",
|
||||
@@ -72,11 +72,11 @@ dependencies = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"drf-spectacular-sidecar==2026.3.1",
|
||||
"drf-spectacular-sidecar==2026.1.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.11.0",
|
||||
"pyfakefs==6.1.5",
|
||||
"ipython==9.10.0",
|
||||
"pyfakefs==6.1.1",
|
||||
"pylint-django==2.7.0",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.0.0",
|
||||
@@ -84,17 +84,12 @@ dev = [
|
||||
"pytest==9.0.2",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.26.0",
|
||||
"ruff==0.15.6",
|
||||
"responses==0.25.8",
|
||||
"ruff==0.15.1",
|
||||
"types-requests==2.32.4.20260107",
|
||||
]
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = [
|
||||
"core",
|
||||
"demo",
|
||||
"meet"
|
||||
]
|
||||
module-root = ""
|
||||
source-exclude = [
|
||||
"**/tests/**",
|
||||
|
||||
Generated
+99
-99
@@ -159,30 +159,30 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.42.68"
|
||||
version = "1.42.49"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ae/60c642aa5413e560b671da825329f510b29a77274ed0f580bde77562294d/boto3-1.42.68.tar.gz", hash = "sha256:3f349f967ab38c23425626d130962bcb363e75f042734fe856ea8c5a00eef03c", size = 112761, upload-time = "2026-03-13T19:32:17.137Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/91/105aa17e0f3a566d33e2d8a3b32a70f553b1ad500d9756c6dd63991d8354/boto3-1.42.49.tar.gz", hash = "sha256:9cd252f640567b86e92b0a8ffdd4ade9a3018ee357c724bff6a21b8c8a41be0c", size = 112877, upload-time = "2026-02-13T20:29:57.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/f6/dc6e993479dbb597d68223fbf61cb026511737696b15bd7d2a33e9b2c24f/boto3-1.42.68-py3-none-any.whl", hash = "sha256:dbff353eb7dc93cbddd7926ed24793e0174c04adbe88860dfa639568442e4962", size = 140556, upload-time = "2026-03-13T19:32:14.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b1/1fa30cd7b26617d59efbe3a4f3660a5b8b397a4623bf1e67016c4cb6dd0e/boto3-1.42.49-py3-none-any.whl", hash = "sha256:99e1df4361c3f6ff6ade65803c043ea96314826134962dd3b385433b309eb819", size = 140606, upload-time = "2026-02-13T20:29:55.366Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.42.73"
|
||||
version = "1.42.64"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/3c/ac4bc939da695d2c648bf28f7b204ab741e4504e81749ccf943403cc07ca/botocore-1.42.64.tar.gz", hash = "sha256:4ee2aece227b9171ace8b749af694a77ab984fceab1639f2626bd0d6fb1aa69d", size = 14967869, upload-time = "2026-03-09T19:51:46.213Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/0f/a0feb9a93da8f583217432dce71ce1940d6d8aa5884bad340872a504ba3f/botocore-1.42.64-py3-none-any.whl", hash = "sha256:f77c5cb76ed30576ed0bc73b591265d03dddffff02a9208d3ee0c790f43d3cd2", size = 14641339, upload-time = "2026-03-09T19:51:41.244Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -545,14 +545,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dj-database-url"
|
||||
version = "3.1.2"
|
||||
version = "3.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/f6/00b625e9d371b980aa261011d0dc906a16444cb688f94215e0dc86996eb5/dj_database_url-3.1.2.tar.gz", hash = "sha256:63c20e4bbaa51690dfd4c8d189521f6bf6bc9da9fcdb23d95d2ee8ee87f9ec62", size = 11490, upload-time = "2026-02-19T15:30:23.638Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/c6/88676a7333fb7c668e626b55f8bfc8527dd863973eb1c40412b95d27747d/dj_database_url-3.1.0.tar.gz", hash = "sha256:d80218426b83f9302c8d27d4fccf52de5cf0cab179f0645fb2839f37605d1353", size = 7924, upload-time = "2026-01-04T09:18:32.693Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/a9/57c66006373381f1d3e5bd94216f1d371228a89f443d3030e010f73dd198/dj_database_url-3.1.2-py3-none-any.whl", hash = "sha256:544e015fee3efa5127a1eb1cca465f4ace578265b3671fe61d0ed7dbafb5ec8a", size = 8953, upload-time = "2026-02-19T15:30:39.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/1b/e84f7472ab0bdacc3fd09556eb4dd40d88246941d465cc103b36a8dabcd8/dj_database_url-3.1.0-py3-none-any.whl", hash = "sha256:155a56fbbecbaaf1348ccd73bf29138b4c9988363ba08261a0f0145e392e638c", size = 8849, upload-time = "2026-01-04T09:18:43.77Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -633,7 +633,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-lasuite"
|
||||
version = "0.0.25"
|
||||
version = "0.0.24"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
@@ -644,9 +644,9 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "requests-toolbelt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/bc/5826a3e5ada5dad1ff6af9a2e1eb598a0dc2cb9c4d2dbf011f1bbb310c9e/django_lasuite-0.0.25.tar.gz", hash = "sha256:ee44783942e6ead74a732f6d7280c5fca961b66581350ce01c0589c3e80684cf", size = 34819, upload-time = "2026-03-10T13:40:28.705Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/dc/4002ba20b964237a609628d53b950c5aecf6387119058831e0ee33176f59/django_lasuite-0.0.24.tar.gz", hash = "sha256:3231b0178a2187405c8faae447225c5dd069263a9a09e73e2a36427be4bf2388", size = 34293, upload-time = "2026-02-11T12:25:42.558Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2f/819ebbc9d3a4b8233cef1e9b0b607724e4acf527988393b04a4036b9c235/django_lasuite-0.0.25-py3-none-any.whl", hash = "sha256:4c10f625005cd41d05e8d34269f1a3b58fea8be5296528bd58596227f50d9884", size = 54034, upload-time = "2026-03-10T13:40:26.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/90/9170b8f443ab5d34b32d7cd386ce3b38ca35baae966da4a1d77b4f4ab3d2/django_lasuite-0.0.24-py3-none-any.whl", hash = "sha256:c37e0b3606a23fd4094ca435fa4edf7a64d325be64b0310fa6577f75d515d856", size = 52620, upload-time = "2026-02-11T12:25:40.626Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -737,11 +737,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dockerflow"
|
||||
version = "2026.3.4"
|
||||
version = "2026.1.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/03/935f8399094611981b37634a7ee078484b3995f744135339e51385638f6c/dockerflow-2026.3.4.tar.gz", hash = "sha256:6cc7fcab2a078d06aeca8b02e22fd7f6766dca322fb30aed6910684dbc08690c", size = 68701, upload-time = "2026-03-04T09:01:28.178Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/08/7abe8458ce6fa82e694ba9b792b4158596fd681782976699d3f15642a23a/dockerflow-2026.1.26.tar.gz", hash = "sha256:c45667ec078944a468b9a634c866587b4cf31559d0e6f369fbadd21edce66b38", size = 68042, upload-time = "2026-01-26T15:30:06.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/b9/2d7bf30eb0c24e60e61e4f6ae7574e123d3fa51b39a612e146c94158446a/dockerflow-2026.3.4-py2.py3-none-any.whl", hash = "sha256:38fe0ad30a4aedf68e589919695d502fc6f584a761b56a91f56e80cbadfbf61a", size = 36424, upload-time = "2026-03-04T09:01:26.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/88/26cec1e8ce09b4022233f232962fba72650c22941d7a609d10b9eec9f748/dockerflow-2026.1.26-py2.py3-none-any.whl", hash = "sha256:26f02d8842672921b2a49f1e019fac54ed2ee69dc673ed80f7b237b67394c2be", size = 35992, upload-time = "2026-01-26T15:30:05.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -763,14 +763,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "drf-spectacular-sidecar"
|
||||
version = "2026.3.1"
|
||||
version = "2026.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/42/2f8c1b2846399d47094ec414bc0d6a7cce7ba95fd6545a97285eee89f7f1/drf_spectacular_sidecar-2026.3.1.tar.gz", hash = "sha256:5b7fedad66e3851f2f442480792c08115d79217959d01645b93d3d2258938be1", size = 2461501, upload-time = "2026-03-01T11:31:19.708Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/81/c7b0e3ccbd5a039c4f4fcfecf88391a666ca1406a953886e2f39295b1c90/drf_spectacular_sidecar-2026.1.1.tar.gz", hash = "sha256:6f7c173a8ddbbbdafc7a27e028614b65f07a89ca90f996a432d57460463b56be", size = 2468060, upload-time = "2026-01-01T11:27:12.682Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/28/2d5e64d101ebc5180674fcaf7b5a35e398e2f8d9688b2e8d52b0e1394e7d/drf_spectacular_sidecar-2026.3.1-py3-none-any.whl", hash = "sha256:864edb83e022e13e3941c325c3cc0c954c843fa2e1d0bc95e81887664b2d3dad", size = 2481725, upload-time = "2026-03-01T11:31:18.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/96/38725edda526f3e9e597f531beeec94b0ef433d9494f06a13b7636eecb6e/drf_spectacular_sidecar-2026.1.1-py3-none-any.whl", hash = "sha256:af8df62f1b594ec280351336d837eaf2402ab25a6bc2a1fad7aee9935821070f", size = 2489520, upload-time = "2026-01-01T11:27:11.056Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -976,7 +976,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ipython"
|
||||
version = "9.11.0"
|
||||
version = "9.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -990,9 +990,9 @@ dependencies = [
|
||||
{ name = "stack-data" },
|
||||
{ name = "traitlets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/aa/898dec789a05731cd5a9f50605b7b44a72bd198fd0d4528e11fc610177cc/ipython-9.10.0-py3-none-any.whl", hash = "sha256:c6ab68cc23bba8c7e18e9b932797014cc61ea7fd6f19de180ab9ba73e65ee58d", size = 622774, upload-time = "2026-02-02T10:00:31.503Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1157,7 +1157,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.12.0"
|
||||
version = "1.10.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1222,24 +1222,24 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = "==3.13.3" },
|
||||
{ name = "boto3", specifier = "==1.42.68" },
|
||||
{ name = "boto3", specifier = "==1.42.49" },
|
||||
{ name = "brevo-python", specifier = "==1.2.0" },
|
||||
{ name = "brotli", specifier = "==1.2.0" },
|
||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.2" },
|
||||
{ name = "dj-database-url", specifier = "==3.1.2" },
|
||||
{ name = "dj-database-url", specifier = "==3.1.0" },
|
||||
{ name = "django", specifier = "==5.2.12" },
|
||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||
{ name = "django-countries", specifier = "==8.2.0" },
|
||||
{ name = "django-filter", specifier = "==25.2" },
|
||||
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.25" },
|
||||
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.24" },
|
||||
{ name = "django-parler", specifier = "==2.3" },
|
||||
{ name = "django-pydantic-field", specifier = "==0.5.4" },
|
||||
{ name = "django-redis", specifier = "==6.0.0" },
|
||||
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
||||
{ name = "django-timezone-field", specifier = ">=5.1" },
|
||||
{ name = "djangorestframework", specifier = "==3.16.1" },
|
||||
{ name = "dockerflow", specifier = "==2026.3.4" },
|
||||
{ name = "dockerflow", specifier = "==2026.1.26" },
|
||||
{ name = "drf-spectacular", specifier = "==0.29.0" },
|
||||
{ name = "easy-thumbnails", specifier = "==2.10.1" },
|
||||
{ name = "factory-boy", specifier = "==3.3.3" },
|
||||
@@ -1249,25 +1249,25 @@ requires-dist = [
|
||||
{ name = "markdown", specifier = "==3.10.2" },
|
||||
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.3" },
|
||||
{ name = "pydantic", specifier = "==2.12.5" },
|
||||
{ name = "pyjwt", specifier = "==2.12.1" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.2" },
|
||||
{ name = "pydantic", specifier = "==2.12.4" },
|
||||
{ name = "pyjwt", specifier = "==2.11.0" },
|
||||
{ name = "python-frontmatter", specifier = "==1.1.0" },
|
||||
{ name = "python-magic", specifier = "==0.4.27" },
|
||||
{ name = "redis", specifier = "==5.2.1" },
|
||||
{ name = "requests", specifier = "==2.32.5" },
|
||||
{ name = "sentry-sdk", specifier = "==2.54.0" },
|
||||
{ name = "whitenoise", specifier = "==6.12.0" },
|
||||
{ name = "sentry-sdk", specifier = "==2.53.0" },
|
||||
{ name = "whitenoise", specifier = "==6.11.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "django-extensions", specifier = "==4.1" },
|
||||
{ name = "drf-spectacular-sidecar", specifier = "==2026.3.1" },
|
||||
{ name = "drf-spectacular-sidecar", specifier = "==2026.1.1" },
|
||||
{ name = "freezegun", specifier = "==1.5.5" },
|
||||
{ name = "ipdb", specifier = "==0.13.13" },
|
||||
{ name = "ipython", specifier = "==9.11.0" },
|
||||
{ name = "pyfakefs", specifier = "==6.1.5" },
|
||||
{ name = "ipython", specifier = "==9.10.0" },
|
||||
{ name = "pyfakefs", specifier = "==6.1.1" },
|
||||
{ name = "pylint", specifier = "<4.0.0" },
|
||||
{ name = "pylint-django", specifier = "==2.7.0" },
|
||||
{ name = "pytest", specifier = "==9.0.2" },
|
||||
@@ -1275,8 +1275,8 @@ dev = [
|
||||
{ name = "pytest-django", specifier = "==4.12.0" },
|
||||
{ name = "pytest-icdiff", specifier = "==0.9" },
|
||||
{ name = "pytest-xdist", specifier = "==3.8.0" },
|
||||
{ name = "responses", specifier = "==0.26.0" },
|
||||
{ name = "ruff", specifier = "==0.15.6" },
|
||||
{ name = "responses", specifier = "==0.25.8" },
|
||||
{ name = "ruff", specifier = "==0.15.1" },
|
||||
{ name = "types-requests", specifier = "==2.32.4.20260107" },
|
||||
]
|
||||
|
||||
@@ -1595,14 +1595,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.3.3"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/1a/7d9ef4fdc13ef7f15b934c393edc97a35c281bb7d3c3329fbfcbe915a7c2/psycopg-3.3.2.tar.gz", hash = "sha256:707a67975ee214d200511177a6a80e56e654754c9afca06a7194ea6bbfde9ca7", size = 165630, upload-time = "2025-12-06T17:34:53.899Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/51/2779ccdf9305981a06b21a6b27e8547c948d85c41c76ff434192784a4c93/psycopg-3.3.2-py3-none-any.whl", hash = "sha256:3e94bc5f4690247d734599af56e51bae8e0db8e4311ea413f801fef82b14a99b", size = 212774, upload-time = "2025-12-06T17:31:41.414Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1612,31 +1612,31 @@ binary = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-binary"
|
||||
version = "3.3.3"
|
||||
version = "3.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/73/7ca7cb22b9ac7393fb5de7d28ca97e8347c375c8498b3bff2c99c1f38038/psycopg_binary-3.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc5a189e89cbfff174588665bb18d28d2d0428366cc9dae5864afcaa2e57380b", size = 4579068, upload-time = "2025-12-06T17:33:39.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/42/0cf38ff6c62c792fc5b55398a853a77663210ebd51ed6f0c4a05b06f95a6/psycopg_binary-3.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:083c2e182be433f290dc2c516fd72b9b47054fcd305cce791e0a50d9e93e06f2", size = 4657520, upload-time = "2025-12-06T17:33:42.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/60/df846bc84cbf2231e01b0fff48b09841fe486fa177665e50f4995b1bfa44/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ac230e3643d1c436a2dfb59ca84357dfc6862c9f372fc5dbd96bafecae581f9f", size = 5452086, upload-time = "2025-12-06T17:33:46.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/85/30c846a00db86b1b53fd5bfd4b4edfbd0c00de8f2c75dd105610bd7568fc/psycopg_binary-3.3.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8c899a540f6c7585cee53cddc929dd4d2db90fd828e37f5d4017b63acbc1a5d", size = 5131125, upload-time = "2025-12-06T17:33:50.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/15/9968732013373f36f8a2a3fb76104dffc8efd9db78709caa5ae1a87b1f80/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50ff10ab8c0abdb5a5451b9315538865b50ba64c907742a1385fdf5f5772b73e", size = 6722914, upload-time = "2025-12-06T17:33:54.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/ba/29e361fe02143ac5ff5a1ca3e45697344cfbebe2eaf8c4e7eec164bff9a0/psycopg_binary-3.3.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:23d2594af848c1fd3d874a9364bef50730124e72df7bb145a20cb45e728c50ed", size = 4966081, upload-time = "2025-12-06T17:33:58.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/45/1be90c8f1a1a237046903e91202fb06708745c179f220b361d6333ed7641/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fe6b4ead3bbbe27244ea224fcd1f53cb119afc38b71a2f3ce570149a03e30", size = 4493332, upload-time = "2025-12-06T17:34:02.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b5/bbdc07d5f0a5e90c617abd624368182aa131485e18038b2c6c85fc054aed/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:742ce48cde825b8e52fb1a658253d6d1ff66d152081cbc76aa45e2986534858d", size = 4170781, upload-time = "2025-12-06T17:34:05.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/2a/0d45e4f4da2bd78c3237ffa03475ef3751f69a81919c54a6e610eb1a7c96/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e22bf6b54df994aff37ab52695d635f1ef73155e781eee1f5fa75bc08b58c8da", size = 3910544, upload-time = "2025-12-06T17:34:08.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/62/a8e0f092f4dbef9a94b032fb71e214cf0a375010692fbe7493a766339e47/psycopg_binary-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8db9034cde3bcdafc66980f0130813f5c5d19e74b3f2a19fb3cfbc25ad113121", size = 4220070, upload-time = "2025-12-06T17:34:11.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/e6/5fc8d8aff8afa114bb4a94a0341b9309311e8bf3ab32d816032f8b984d4e/psycopg_binary-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:df65174c7cf6b05ea273ce955927d3270b3a6e27b0b12762b009ce6082b8d3fc", size = 3540922, upload-time = "2025-12-06T17:34:14.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/75/ad18c0b97b852aba286d06befb398cc6d383e9dfd0a518369af275a5a526/psycopg_binary-3.3.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9ca24062cd9b2270e4d77576042e9cc2b1d543f09da5aba1f1a3d016cea28390", size = 4596371, upload-time = "2025-12-06T17:34:18.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/79/91649d94c8d89f84af5da7c9d474bfba35b08eb8f492ca3422b08f0a6427/psycopg_binary-3.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c749770da0947bc972e512f35366dd4950c0e34afad89e60b9787a37e97cb443", size = 4675139, upload-time = "2025-12-06T17:34:21.374Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ac/b26e004880f054549ec9396594e1ffe435810b0673e428e619ed722e4244/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03b7cd73fb8c45d272a34ae7249713e32492891492681e3cf11dff9531cf37e9", size = 5456120, upload-time = "2025-12-06T17:34:25.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/410681dccd6f2999fb115cc248521ec50dd2b0aba66ae8de7e81efdebbee/psycopg_binary-3.3.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43b130e3b6edcb5ee856c7167ccb8561b473308c870ed83978ae478613764f1c", size = 5133484, upload-time = "2025-12-06T17:34:28.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/30/ebbab99ea2cfa099d7b11b742ce13415d44f800555bfa4ad2911dc645b71/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1feba5a8c617922321aef945865334e468337b8fc5c73074f5e63143013b5a", size = 6731818, upload-time = "2025-12-06T17:34:33.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/02/d260646253b7ad805d60e0de47f9b811d6544078452579466a098598b6f4/psycopg_binary-3.3.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cabb2a554d9a0a6bf84037d86ca91782f087dfff2a61298d0b00c19c0bc43f6d", size = 4983859, upload-time = "2025-12-06T17:34:36.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/8d/e778d7bad1a7910aa36281f092bd85c5702f508fd9bb0ea2020ffbb6585c/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74bc306c4b4df35b09bc8cecf806b271e1c5d708f7900145e4e54a2e5dedfed0", size = 4516388, upload-time = "2025-12-06T17:34:40.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/f1/64e82098722e2ab3521797584caf515284be09c1e08a872551b6edbb0074/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d79b0093f0fbf7a962d6a46ae292dc056c65d16a8ee9361f3cfbafd4c197ab14", size = 4192382, upload-time = "2025-12-06T17:34:43.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/d0/c20f4e668e89494972e551c31be2a0016e3f50d552d7ae9ac07086407599/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1586e220be05547c77afc326741dd41cc7fba38a81f9931f616ae98865439678", size = 3928660, upload-time = "2025-12-06T17:34:46.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e1/99746c171de22539fd5eb1c9ca21dc805b54cfae502d7451d237d1dbc349/psycopg_binary-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:458696a5fa5dad5b6fb5d5862c22454434ce4fe1cf66ca6c0de5f904cbc1ae3e", size = 4239169, upload-time = "2025-12-06T17:34:49.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1668,7 +1668,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
version = "2.12.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@@ -1676,9 +1676,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1736,11 +1736,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyfakefs"
|
||||
version = "6.1.5"
|
||||
version = "6.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/87/fe3f1fbbf18480b57e6b195d4d416e01d8e55bf88743982ccb3a8845c2a2/pyfakefs-6.1.5.tar.gz", hash = "sha256:d70fb1c622a1d5c729462d31bb58eead99c1830986bc049bf8975c480a49d19b", size = 226814, upload-time = "2026-03-15T19:05:18.025Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/84/843a50cfde48d6004cbf540b38b82a39737273cc76b51da6b456fea75de0/pyfakefs-6.1.1.tar.gz", hash = "sha256:1c166fdba85f3f288f89b2a410e05712914d1f7ea77b920e960fe0cbbea1294d", size = 226032, upload-time = "2026-02-09T19:44:19.539Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/d8/a60fb94ab2b5a9bf0e38d6e93667904d4e844d8b7e9cfb5ef0304d68da6b/pyfakefs-6.1.5-py3-none-any.whl", hash = "sha256:2c482f36bad7d1f7516d58d17818fb0dbe397a3372ec5274869386a9b7cd4883", size = 239885, upload-time = "2026-03-15T19:05:16.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/e2/1d74c7663aa38e405c25a30c26e8a1b92c06f8947118b9617042f4290ade/pyfakefs-6.1.1-py3-none-any.whl", hash = "sha256:e2c30fd0b7736ba03785bbc20b86de2312623cf7a11a7de334042ab0de23d46f", size = 239630, upload-time = "2026-02-09T19:44:18.093Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1754,11 +1754,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1992,16 +1992,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "responses"
|
||||
version = "0.26.0"
|
||||
version = "0.25.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/b4/b7e040379838cc71bf5aabdb26998dfbe5ee73904c92c1c161faf5de8866/responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4", size = 81303, upload-time = "2026-02-19T14:38:05.574Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/95/89c054ad70bfef6da605338b009b2e283485835351a9935c7bfbfaca7ffc/responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4", size = 79320, upload-time = "2025-08-08T19:01:46.709Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/04/7f73d05b556da048923e31a0cc878f03be7c5425ed1f268082255c75d872/responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37", size = 35099, upload-time = "2026-02-19T14:38:03.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/4c/cc276ce57e572c102d9542d383b2cfd551276581dc60004cb94fe8774c11/responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c", size = 34769, upload-time = "2025-08-08T19:01:45.018Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2072,27 +2072,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.6"
|
||||
version = "0.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2109,15 +2109,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.54.0"
|
||||
version = "2.53.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2271,11 +2271,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "whitenoise"
|
||||
version = "6.12.0"
|
||||
version = "6.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/95/8c81ec6b6ebcbf8aca2de7603070ccf37dbb873b03f20708e0f7c1664bc6/whitenoise-6.11.0.tar.gz", hash = "sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f", size = 26432, upload-time = "2025-09-18T09:16:10.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/e9/4366332f9295fe0647d7d3251ce18f5615fbcb12d02c79a26f8dba9221b3/whitenoise-6.11.0-py3-none-any.whl", hash = "sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258", size = 20197, upload-time = "2025-09-18T09:16:09.754Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+16
-889
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.12.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -10,9 +10,7 @@
|
||||
"preview": "vite preview",
|
||||
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
||||
"format": "prettier --write ./src",
|
||||
"check": "prettier --check ./src",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
@@ -61,12 +59,10 @@
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"jsdom": "^29.0.2",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "^4.1.3"
|
||||
"vite-tsconfig-paths": "6.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 11 KiB |
@@ -15,19 +15,7 @@ export const fetchApi = async <T = Record<string, unknown>>(
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
let result: T
|
||||
if (response.status === 204) {
|
||||
result = undefined as T
|
||||
} else {
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
result = undefined as T
|
||||
} else {
|
||||
result = (await response.json()) as T
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, result)
|
||||
}
|
||||
|
||||
@@ -5,5 +5,4 @@ export const keys = {
|
||||
requestEntry: 'requestEntry',
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
roomCreationCallback: 'roomCreationCallback',
|
||||
files: 'files',
|
||||
}
|
||||
|
||||
@@ -30,13 +30,6 @@ export interface ApiConfig {
|
||||
expiration_days?: number
|
||||
max_duration?: number
|
||||
}
|
||||
background_image: {
|
||||
upload_is_enabled: boolean
|
||||
max_size: number
|
||||
max_count_by_user: number
|
||||
allowed_extensions: string[]
|
||||
allowed_mimetypes: string[]
|
||||
}
|
||||
subtitle: {
|
||||
enabled: boolean
|
||||
}
|
||||
@@ -52,9 +45,6 @@ export interface ApiConfig {
|
||||
enable_firefox_proxy_workaround: boolean
|
||||
default_sources: string[]
|
||||
}
|
||||
encryption?: {
|
||||
enabled: boolean
|
||||
}
|
||||
transcription_destination?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export const Avatar = ({
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? ''
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -70,7 +70,7 @@ export const Avatar = ({
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
lineHeight: 1,
|
||||
marginTop: '-0.3rem',
|
||||
})}
|
||||
>
|
||||
{initial}
|
||||
|
||||
@@ -3,10 +3,8 @@ import { BackendLanguage } from '@/utils/languages'
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string | null
|
||||
short_name: string | null
|
||||
full_name: string
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
default_encryption: boolean
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { type ApiUser } from './ApiUser'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export type ApiUserPreferences = Partial<
|
||||
Pick<ApiUser, 'timezone' | 'language' | 'default_encryption'>
|
||||
> & { id: string }
|
||||
export type ApiUserPreferences = Pick<ApiUser, 'id' | 'timezone' | 'language'>
|
||||
|
||||
export const updateUserPreferences = async ({
|
||||
user,
|
||||
}: {
|
||||
user: ApiUserPreferences
|
||||
}): Promise<ApiUser> => {
|
||||
const { id, ...payload } = user
|
||||
return await fetchApi(`/users/${id}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
return await fetchApi(`/users/${user.id}/`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Small banner shown on a participant tile when LiveKit raises an
|
||||
* EncryptionError for that participant — typically a passphrase/key
|
||||
* mismatch ("you and they don't share the same encryption key"). The
|
||||
* avatar stays visible behind the banner.
|
||||
*
|
||||
* Cleared automatically once frames decrypt again
|
||||
* (ParticipantEncryptionStatusChanged with encrypted=true).
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Participant, RoomEvent } from 'livekit-client'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { RiLockFill } from '@remixicon/react'
|
||||
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
|
||||
interface Props {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export function DecryptionFailedTileOverlay({ participant }: Props) {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'encryption.decryptionFailed',
|
||||
})
|
||||
const room = useRoomContext()
|
||||
const roomData = useRoomData()
|
||||
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomData?.is_encrypted) return
|
||||
if (participant.isLocal) return
|
||||
|
||||
const identity = participant.identity
|
||||
|
||||
const onError = (_err: Error, p?: Participant) => {
|
||||
if (p?.identity === identity) setFailed(true)
|
||||
}
|
||||
const onStatus = (encrypted: boolean, p?: Participant) => {
|
||||
if (p?.identity === identity && encrypted) setFailed(false)
|
||||
}
|
||||
|
||||
room.on(RoomEvent.EncryptionError, onError)
|
||||
room.on(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
|
||||
return () => {
|
||||
room.off(RoomEvent.EncryptionError, onError)
|
||||
room.off(RoomEvent.ParticipantEncryptionStatusChanged, onStatus)
|
||||
}
|
||||
}, [room, roomData?.is_encrypted, participant])
|
||||
|
||||
if (!failed) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '2.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.6rem 1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.3rem',
|
||||
maxWidth: '85%',
|
||||
zIndex: 4,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
color: '#f87171',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<RiLockFill size={14} />
|
||||
<span>{t('title')}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: '#d1d5db',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.4,
|
||||
maxWidth: '22rem',
|
||||
}}
|
||||
>
|
||||
{t('body')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* When the participant who paused encryption sees that *both* recording and
|
||||
* transcription have stopped, automatically broadcast `ENCRYPTION_RESUMED`.
|
||||
*
|
||||
* Other participants don't run this watcher: only the pauser can resume
|
||||
* (they're the one with `pausedByMe=true`). If they leave the room, the next
|
||||
* leader/admin can manually resume from the Settings panel — or in v1 the
|
||||
* room simply stays paused for the rest of the session.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useIsRecording } from '@livekit/components-react'
|
||||
import { RecordingMode, useRecordingStatuses } from '@/features/recording'
|
||||
import { EncryptionPhase } from './encryptionStatusTypes'
|
||||
import { useEncryptionStatus } from './useEncryptionStatus'
|
||||
|
||||
export function EncryptionAutoResumeWatcher() {
|
||||
const { phase, pausedByMe, resumeEncryption } = useEncryptionStatus()
|
||||
const isLiveKitRecording = useIsRecording()
|
||||
const transcriptStatuses = useRecordingStatuses(RecordingMode.Transcript)
|
||||
const screenRecStatuses = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
|
||||
// Edge guard: avoid resuming on the initial render before anything has
|
||||
// actually started. We only resume after we've observed an active state.
|
||||
const wasActiveRef = useRef(false)
|
||||
const isAnyActive =
|
||||
isLiveKitRecording ||
|
||||
transcriptStatuses.isActive ||
|
||||
screenRecStatuses.isActive
|
||||
|
||||
useEffect(() => {
|
||||
if (isAnyActive) {
|
||||
wasActiveRef.current = true
|
||||
}
|
||||
}, [isAnyActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== EncryptionPhase.PAUSED) return
|
||||
if (!pausedByMe) return
|
||||
if (!wasActiveRef.current) return
|
||||
if (isAnyActive) return
|
||||
|
||||
void resumeEncryption()
|
||||
}, [phase, pausedByMe, isAnyActive, resumeEncryption])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Shown when the URL hash and the room's `is_encrypted` flag disagree.
|
||||
*
|
||||
* - missingPassphrase: room is encrypted on the server, but the URL has no
|
||||
* (or an invalid) passphrase. The user opened the wrong link.
|
||||
* - unexpectedPassphrase: the URL has a passphrase, but the server says the
|
||||
* room is not encrypted. Either the room was created differently or the
|
||||
* link looks tampered with — either way, joining as "encrypted" would
|
||||
* leave the user alone in an encrypted bubble. Better to bail.
|
||||
*/
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Center } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAlertLine, RiLockUnlockLine } from '@remixicon/react'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import {
|
||||
generateRoomId,
|
||||
useCreateRoom,
|
||||
} from '@/features/rooms'
|
||||
import { generatePassphrase } from './passphrase'
|
||||
|
||||
interface Props {
|
||||
reason: 'missingPassphrase' | 'unexpectedPassphrase'
|
||||
}
|
||||
|
||||
export function EncryptionMismatchScreen({ reason }: Props) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.mismatch' })
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
|
||||
const handleCreateFresh = async () => {
|
||||
const slug = generateRoomId()
|
||||
const hash = generatePassphrase()
|
||||
const room = await createRoom({ slug, isEncrypted: true })
|
||||
navigateTo('room', room.slug, {
|
||||
state: { create: true, initialRoomData: room },
|
||||
})
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}#${hash}`
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<CenteredContent withBackButton>
|
||||
<Center>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '420px',
|
||||
padding: '2rem',
|
||||
borderRadius: '1rem',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.06)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '3.5rem',
|
||||
height: '3.5rem',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#fffbeb',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{reason === 'missingPassphrase' ? (
|
||||
<RiLockUnlockLine size={28} color="#b45309" />
|
||||
) : (
|
||||
<RiAlertLine size={28} color="#b45309" />
|
||||
)}
|
||||
</div>
|
||||
<Text
|
||||
as="h2"
|
||||
className={css({ fontWeight: 700, fontSize: '1.15rem' })}
|
||||
>
|
||||
{t(`${reason}.title`)}
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
className={css({ fontSize: '0.9rem', color: 'greyscale.700' })}
|
||||
>
|
||||
{t(`${reason}.body`)}
|
||||
</Text>
|
||||
<Button variant="primary" onPress={handleCreateFresh}>
|
||||
{t('createFresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</Center>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
/**
|
||||
* In-call encryption state machine and pause protocol.
|
||||
*
|
||||
* Three phases:
|
||||
* - UNENCRYPTED — the room is not end-to-end encrypted.
|
||||
* - ENCRYPTED — E2EE is active; frames are encrypted with the URL passphrase.
|
||||
* - PAUSED — encryption is temporarily paused for this session, typically
|
||||
* so the SFU can record / transcribe.
|
||||
*
|
||||
* The "paused" state is intentionally ephemeral: it is never persisted in the
|
||||
* database. The truth source for "this call is encrypted" is the presence of
|
||||
* the passphrase in the URL hash, not a server-side flag — a hacked server
|
||||
* cannot fabricate a passphrase that all participants happen to share.
|
||||
*
|
||||
* Pause is broadcast over a LiveKit reliable data channel. While the sender
|
||||
* has not yet flipped its own state, the message itself travels encrypted,
|
||||
* which is the trust anchor: only callers who hold the passphrase can produce
|
||||
* frames everyone can decrypt.
|
||||
*
|
||||
* Pause is reversible: when both recording and transcription have stopped,
|
||||
* the participant who initiated the pause broadcasts ENCRYPTION_RESUMED and
|
||||
* everyone re-enables E2EE with the same URL passphrase.
|
||||
*
|
||||
* Initiation: admins can always pause/resume. If no admin is in the room and
|
||||
* a pause has already been observed in this session (everSeenPause), the
|
||||
* leader (oldest non-SIP participant) may also pause/resume — this covers
|
||||
* the "the admin left mid-call" edge case without granting unsolicited
|
||||
* pause power to non-admins.
|
||||
*/
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
DataPacket_Kind,
|
||||
Participant,
|
||||
ParticipantKind,
|
||||
RemoteParticipant,
|
||||
RoomEvent,
|
||||
} from 'livekit-client'
|
||||
import { EncryptionStatusContext } from './encryptionStatusContextValue'
|
||||
import { EncryptionPhase, PauseReason } from './encryptionStatusTypes'
|
||||
|
||||
const ENCRYPTION_TOPIC = 'encryption-state'
|
||||
const PROBE_RESPONSE_GRACE_MS = 2500
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
const textDecoder = new TextDecoder()
|
||||
|
||||
interface ProtocolMessage {
|
||||
type:
|
||||
| 'ENCRYPTION_PAUSED'
|
||||
| 'ENCRYPTION_RESUMED'
|
||||
| 'ENCRYPTION_STATUS_PROBE'
|
||||
reason?: PauseReason
|
||||
/** Sender's `joinedAt` timestamp; used in leader election. */
|
||||
senderJoinedAt?: number
|
||||
/** Whether the sender is a room admin/owner. */
|
||||
senderIsAdmin?: boolean
|
||||
}
|
||||
|
||||
function encodeMessage(msg: ProtocolMessage): Uint8Array {
|
||||
return textEncoder.encode(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
function decodeMessage(payload: Uint8Array): ProtocolMessage | null {
|
||||
try {
|
||||
return JSON.parse(textDecoder.decode(payload)) as ProtocolMessage
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isParticipantAdmin(participant: Participant | undefined): boolean {
|
||||
return participant?.attributes?.room_admin === 'true'
|
||||
}
|
||||
|
||||
function isParticipantPhoneOrSip(p: Participant): boolean {
|
||||
return p.kind === ParticipantKind.SIP
|
||||
}
|
||||
|
||||
interface EncryptionStatusProviderProps {
|
||||
children: ReactNode
|
||||
/** Whether this room is end-to-end encrypted. */
|
||||
isEncrypted: boolean
|
||||
/** Called when the local client should toggle E2EE on/off. */
|
||||
onPhaseChange?: (phase: EncryptionPhase) => void
|
||||
/**
|
||||
* Optional hook to mirror local pause/resume to the room's
|
||||
* server-side encryption_paused field. When provided, recording and
|
||||
* transcription pauses also propagate to the SIP gateway (so phone
|
||||
* callers transition out of placeholder mode automatically).
|
||||
*/
|
||||
setServerEncryptionPaused?: (paused: boolean) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function EncryptionStatusProvider({
|
||||
children,
|
||||
isEncrypted,
|
||||
onPhaseChange,
|
||||
setServerEncryptionPaused,
|
||||
}: EncryptionStatusProviderProps) {
|
||||
const room = useRoomContext()
|
||||
const initialPhase = isEncrypted
|
||||
? EncryptionPhase.ENCRYPTED
|
||||
: EncryptionPhase.UNENCRYPTED
|
||||
const [phase, setPhase] = useState<EncryptionPhase>(initialPhase)
|
||||
const [pauseReason, setPauseReason] = useState<PauseReason | undefined>()
|
||||
const [pausedByMe, setPausedByMe] = useState(false)
|
||||
const everSeenPauseRef = useRef(false)
|
||||
const phaseRef = useRef(phase)
|
||||
phaseRef.current = phase
|
||||
|
||||
// When the encrypted flag changes (e.g. on initial room data load), align
|
||||
// the local phase. The pause path keeps phase=PAUSED across updates.
|
||||
useEffect(() => {
|
||||
if (!isEncrypted && phaseRef.current !== EncryptionPhase.UNENCRYPTED) {
|
||||
setPhase(EncryptionPhase.UNENCRYPTED)
|
||||
setPauseReason(undefined)
|
||||
setPausedByMe(false)
|
||||
} else if (
|
||||
isEncrypted &&
|
||||
phaseRef.current === EncryptionPhase.UNENCRYPTED
|
||||
) {
|
||||
setPhase(EncryptionPhase.ENCRYPTED)
|
||||
}
|
||||
}, [isEncrypted])
|
||||
|
||||
// Push phase transitions to LiveKit (E2EE on/off + republish).
|
||||
useEffect(() => {
|
||||
onPhaseChange?.(phase)
|
||||
}, [phase, onPhaseChange])
|
||||
|
||||
const sendProtocolMessage = useCallback(
|
||||
async (msg: ProtocolMessage, destination?: string[]) => {
|
||||
try {
|
||||
await room.localParticipant.publishData(encodeMessage(msg), {
|
||||
reliable: true,
|
||||
topic: ENCRYPTION_TOPIC,
|
||||
destinationIdentities: destination,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[encryption] failed to publish protocol message', err)
|
||||
}
|
||||
},
|
||||
[room]
|
||||
)
|
||||
|
||||
/**
|
||||
* Determine whether we (locally) consider `sender` legitimate to issue
|
||||
* pause/resume messages.
|
||||
*
|
||||
* Always true for admins. For non-admins, true only if there is no admin
|
||||
* currently in the room AND the sender is the oldest non-SIP participant
|
||||
* we know of (deterministic across peers via joinedAt+identity).
|
||||
*/
|
||||
const isLegitimatePauseSender = useCallback(
|
||||
(sender: RemoteParticipant | undefined): boolean => {
|
||||
if (!sender) return false
|
||||
if (isParticipantAdmin(sender)) return true
|
||||
|
||||
const everyone: Participant[] = [
|
||||
room.localParticipant,
|
||||
...Array.from(room.remoteParticipants.values()),
|
||||
]
|
||||
const adminPresent = everyone.some(isParticipantAdmin)
|
||||
if (adminPresent) return false
|
||||
|
||||
const eligible = everyone.filter((p) => !isParticipantPhoneOrSip(p))
|
||||
const sorted = eligible.sort((a, b) => {
|
||||
const aJ = a.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
|
||||
const bJ = b.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
|
||||
if (aJ !== bJ) return aJ - bJ
|
||||
return a.identity.localeCompare(b.identity)
|
||||
})
|
||||
const leader = sorted[0]
|
||||
return !!leader && leader.identity === sender.identity
|
||||
},
|
||||
[room]
|
||||
)
|
||||
|
||||
/** Same logic but applied to the local participant (am I allowed to act?). */
|
||||
const localCanInitiate = useCallback((): boolean => {
|
||||
if (isParticipantAdmin(room.localParticipant)) return true
|
||||
if (!everSeenPauseRef.current) return false
|
||||
|
||||
const everyone: Participant[] = [
|
||||
room.localParticipant,
|
||||
...Array.from(room.remoteParticipants.values()),
|
||||
]
|
||||
if (everyone.some(isParticipantAdmin)) return false
|
||||
|
||||
const eligible = everyone.filter((p) => !isParticipantPhoneOrSip(p))
|
||||
const sorted = eligible.sort((a, b) => {
|
||||
const aJ = a.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
|
||||
const bJ = b.joinedAt?.getTime() ?? Number.MAX_SAFE_INTEGER
|
||||
if (aJ !== bJ) return aJ - bJ
|
||||
return a.identity.localeCompare(b.identity)
|
||||
})
|
||||
return sorted[0]?.identity === room.localParticipant.identity
|
||||
}, [room])
|
||||
|
||||
const handlePauseAnnouncement = useCallback(
|
||||
(msg: ProtocolMessage, sender?: RemoteParticipant) => {
|
||||
if (!isLegitimatePauseSender(sender)) return
|
||||
everSeenPauseRef.current = true
|
||||
if (phaseRef.current !== EncryptionPhase.ENCRYPTED) return
|
||||
|
||||
setPhase(EncryptionPhase.PAUSED)
|
||||
setPauseReason(msg.reason)
|
||||
setPausedByMe(false)
|
||||
},
|
||||
[isLegitimatePauseSender]
|
||||
)
|
||||
|
||||
const handleResumeAnnouncement = useCallback(
|
||||
(sender?: RemoteParticipant) => {
|
||||
if (!isLegitimatePauseSender(sender)) return
|
||||
if (phaseRef.current !== EncryptionPhase.PAUSED) return
|
||||
|
||||
setPhase(EncryptionPhase.ENCRYPTED)
|
||||
setPauseReason(undefined)
|
||||
setPausedByMe(false)
|
||||
},
|
||||
[isLegitimatePauseSender]
|
||||
)
|
||||
|
||||
const handleProbe = useCallback(
|
||||
(sender: RemoteParticipant) => {
|
||||
if (phaseRef.current !== EncryptionPhase.PAUSED) return
|
||||
// We respond if we ourselves are a legitimate sender for this room.
|
||||
if (!localCanInitiate()) return
|
||||
|
||||
void sendProtocolMessage(
|
||||
{
|
||||
type: 'ENCRYPTION_PAUSED',
|
||||
reason: pauseReason,
|
||||
senderIsAdmin: isParticipantAdmin(room.localParticipant),
|
||||
senderJoinedAt:
|
||||
room.localParticipant.joinedAt?.getTime() ?? Date.now(),
|
||||
},
|
||||
[sender.identity]
|
||||
)
|
||||
},
|
||||
[room, pauseReason, sendProtocolMessage, localCanInitiate]
|
||||
)
|
||||
|
||||
// Subscribe to encryption-channel data messages.
|
||||
useEffect(() => {
|
||||
if (!isEncrypted) return
|
||||
|
||||
const handler = (
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant,
|
||||
_kind?: DataPacket_Kind,
|
||||
topic?: string
|
||||
) => {
|
||||
if (topic !== ENCRYPTION_TOPIC) return
|
||||
const msg = decodeMessage(payload)
|
||||
if (!msg) return
|
||||
if (msg.type === 'ENCRYPTION_PAUSED') {
|
||||
handlePauseAnnouncement(msg, participant)
|
||||
} else if (msg.type === 'ENCRYPTION_RESUMED') {
|
||||
handleResumeAnnouncement(participant)
|
||||
} else if (msg.type === 'ENCRYPTION_STATUS_PROBE' && participant) {
|
||||
handleProbe(participant)
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.DataReceived, handler)
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handler)
|
||||
}
|
||||
}, [
|
||||
room,
|
||||
isEncrypted,
|
||||
handlePauseAnnouncement,
|
||||
handleResumeAnnouncement,
|
||||
handleProbe,
|
||||
])
|
||||
|
||||
// On join, ask the room whether encryption is currently paused.
|
||||
useEffect(() => {
|
||||
if (!isEncrypted) return
|
||||
if (phase !== EncryptionPhase.ENCRYPTED) return
|
||||
|
||||
let cancelled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled) return
|
||||
void sendProtocolMessage({ type: 'ENCRYPTION_STATUS_PROBE' })
|
||||
}, 0)
|
||||
const cleanup = setTimeout(() => {
|
||||
// Nothing to do — if no answer arrived, we stay encrypted.
|
||||
}, PROBE_RESPONSE_GRACE_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
clearTimeout(cleanup)
|
||||
}
|
||||
// we intentionally only run this when joining the encrypted state
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isEncrypted])
|
||||
|
||||
const pauseEncryption = useCallback(
|
||||
async (reason: PauseReason) => {
|
||||
if (phaseRef.current !== EncryptionPhase.ENCRYPTED) return false
|
||||
if (!localCanInitiate()) return false
|
||||
|
||||
everSeenPauseRef.current = true
|
||||
setPhase(EncryptionPhase.PAUSED)
|
||||
setPauseReason(reason)
|
||||
setPausedByMe(true)
|
||||
|
||||
// Mirror to the server's encryption_paused field if a setter was
|
||||
// wired through. This is what makes the SIP gateway transition out
|
||||
// of placeholder mode for recording/transcription too, not just
|
||||
// for an explicit admin pause from the Admin panel.
|
||||
if (setServerEncryptionPaused) {
|
||||
try {
|
||||
await setServerEncryptionPaused(true)
|
||||
} catch (err) {
|
||||
console.error('[encryption] server-side pause failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
await sendProtocolMessage({
|
||||
type: 'ENCRYPTION_PAUSED',
|
||||
reason,
|
||||
senderIsAdmin: isParticipantAdmin(room.localParticipant),
|
||||
senderJoinedAt:
|
||||
room.localParticipant.joinedAt?.getTime() ?? Date.now(),
|
||||
})
|
||||
return true
|
||||
},
|
||||
[room, sendProtocolMessage, localCanInitiate, setServerEncryptionPaused]
|
||||
)
|
||||
|
||||
const resumeEncryption = useCallback(async () => {
|
||||
if (phaseRef.current !== EncryptionPhase.PAUSED) return false
|
||||
if (!localCanInitiate()) return false
|
||||
|
||||
setPhase(EncryptionPhase.ENCRYPTED)
|
||||
setPauseReason(undefined)
|
||||
setPausedByMe(false)
|
||||
|
||||
if (setServerEncryptionPaused) {
|
||||
try {
|
||||
await setServerEncryptionPaused(false)
|
||||
} catch (err) {
|
||||
console.error('[encryption] server-side resume failed', err)
|
||||
}
|
||||
}
|
||||
|
||||
await sendProtocolMessage({
|
||||
type: 'ENCRYPTION_RESUMED',
|
||||
senderIsAdmin: isParticipantAdmin(room.localParticipant),
|
||||
senderJoinedAt: room.localParticipant.joinedAt?.getTime() ?? Date.now(),
|
||||
})
|
||||
return true
|
||||
}, [room, sendProtocolMessage, localCanInitiate, setServerEncryptionPaused])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
phase,
|
||||
pauseReason,
|
||||
pausedByMe,
|
||||
pauseEncryption,
|
||||
resumeEncryption,
|
||||
}),
|
||||
[phase, pauseReason, pausedByMe, pauseEncryption, resumeEncryption]
|
||||
)
|
||||
|
||||
return (
|
||||
<EncryptionStatusContext.Provider value={value}>
|
||||
{children}
|
||||
</EncryptionStatusContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* Transient bottom snackbars announcing encryption state changes:
|
||||
* - "Encryption paused while transcription is on"
|
||||
* - "Encryption was turned off for this meeting"
|
||||
* - "A participant can't decrypt this meeting" (admin only, with CTA)
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { ParticipantKind, RemoteParticipant } from 'livekit-client'
|
||||
import { useRemoteParticipants } from '@livekit/components-react'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { EncryptionPhase, PauseReason } from './encryptionStatusTypes'
|
||||
import { useEncryptionStatus } from './useEncryptionStatus'
|
||||
|
||||
const DISPLAY_DURATION_MS = 8000
|
||||
|
||||
const SnackbarShell = ({ children }: { children: React.ReactNode }) => (
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
bottom: '5rem',
|
||||
right: '1rem',
|
||||
zIndex: 1500,
|
||||
maxWidth: '24rem',
|
||||
padding: '0.85rem 1rem',
|
||||
backgroundColor: '#1e3a5f',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||||
})}
|
||||
role="status"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
function useTransient<T>(value: T, displayMs: number) {
|
||||
const [shown, setShown] = useState<T | null>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) return
|
||||
setShown(value)
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => setShown(null), displayMs)
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
}
|
||||
}, [value, displayMs])
|
||||
|
||||
return [shown, () => setShown(null)] as const
|
||||
}
|
||||
|
||||
export function EncryptionStatusSnackbars() {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.snackbar' })
|
||||
const { phase, pauseReason, pausedByMe } = useEncryptionStatus()
|
||||
const remoteParticipants = useRemoteParticipants()
|
||||
const isAdmin = useIsAdminOrOwner()
|
||||
const { toggleAdmin, isAdminOpen } = useSidePanel()
|
||||
|
||||
const [pauseSignal, setPauseSignal] = useState<{
|
||||
reason?: PauseReason
|
||||
pausedByMe: boolean
|
||||
} | null>(null)
|
||||
|
||||
const previousPhase = useRef(phase)
|
||||
useEffect(() => {
|
||||
if (
|
||||
previousPhase.current !== EncryptionPhase.PAUSED &&
|
||||
phase === EncryptionPhase.PAUSED
|
||||
) {
|
||||
setPauseSignal({ reason: pauseReason, pausedByMe })
|
||||
}
|
||||
previousPhase.current = phase
|
||||
}, [phase, pauseReason, pausedByMe])
|
||||
|
||||
const [pauseToast, dismissPauseToast] = useTransient(
|
||||
pauseSignal,
|
||||
DISPLAY_DURATION_MS
|
||||
)
|
||||
|
||||
// The SIP-blocked snackbar persists as long as a SIP participant is
|
||||
// present in the encrypted room — admin needs to either pause encryption
|
||||
// (Settings → Security → This meeting) or wait for the SIP user to hang
|
||||
// up. Manual dismiss hides it until a different SIP participant arrives.
|
||||
//
|
||||
// useRemoteParticipants re-renders on participant join/leave/state — that
|
||||
// gives us a reactive participant list without manual event listeners,
|
||||
// which is more reliable than the prior subscribe-on-mount approach.
|
||||
const [sipDismissedIdentity, setSipDismissedIdentity] = useState<string | null>(null)
|
||||
|
||||
const isSip = (p: RemoteParticipant) =>
|
||||
p.kind === ParticipantKind.SIP || p.identity.startsWith('sip_')
|
||||
|
||||
const sipParticipant =
|
||||
isAdmin && phase === EncryptionPhase.ENCRYPTED
|
||||
? remoteParticipants.find(isSip) ?? null
|
||||
: null
|
||||
|
||||
const sipLabel = sipParticipant
|
||||
? sipParticipant.name || sipParticipant.identity
|
||||
: null
|
||||
|
||||
const showSipSnack =
|
||||
sipParticipant !== null &&
|
||||
sipDismissedIdentity !== sipParticipant.identity
|
||||
|
||||
return (
|
||||
<>
|
||||
{pauseToast && (
|
||||
<SnackbarShell>
|
||||
<HStack
|
||||
gap="1rem"
|
||||
justify="space-between"
|
||||
alignItems="center"
|
||||
className={css({ width: '100%' })}
|
||||
>
|
||||
<VStack gap="0.15rem" alignItems="start">
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({ color: 'white', fontWeight: 600 })}
|
||||
>
|
||||
{pauseToast.pausedByMe
|
||||
? t('pausedByMeTitle')
|
||||
: t('pausedTitle')}
|
||||
</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
fontSize: '0.8rem',
|
||||
})}
|
||||
>
|
||||
{pauseToast.reason === 'transcript'
|
||||
? t('reasonTranscript')
|
||||
: pauseToast.reason === 'recording'
|
||||
? t('reasonRecording')
|
||||
: pauseToast.reason === 'sip_participant'
|
||||
? t('reasonSip')
|
||||
: t('reasonManual')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
onPress={dismissPauseToast}
|
||||
className={css({ color: 'white !important' })}
|
||||
>
|
||||
{t('dismiss')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SnackbarShell>
|
||||
)}
|
||||
{showSipSnack && phase === EncryptionPhase.ENCRYPTED && (
|
||||
<SnackbarShell>
|
||||
<HStack
|
||||
gap="1rem"
|
||||
justify="space-between"
|
||||
alignItems="center"
|
||||
className={css({ width: '100%' })}
|
||||
>
|
||||
<VStack gap="0.15rem" alignItems="start">
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({ color: 'white', fontWeight: 600 })}
|
||||
>
|
||||
{t('sipTitle')}
|
||||
</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
fontSize: '0.8rem',
|
||||
})}
|
||||
>
|
||||
{t('sipBody', { name: sipLabel })}
|
||||
</Text>
|
||||
</VStack>
|
||||
{!isAdminOpen && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
className={css({ color: 'white !important' })}
|
||||
onPress={toggleAdmin}
|
||||
>
|
||||
{t('openAdmin')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
className={css({ color: 'white !important' })}
|
||||
onPress={() =>
|
||||
sipParticipant &&
|
||||
setSipDismissedIdentity(sipParticipant.identity)
|
||||
}
|
||||
>
|
||||
{t('dismiss')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SnackbarShell>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Tiny per-participant identity confidence pill, shown in encrypted meetings.
|
||||
*
|
||||
* - "ProConnect" → server-verified identity (the participant signed in).
|
||||
* - "Anonymous" → self-declared name; treat with caution.
|
||||
*
|
||||
* Sourced from the `is_authenticated` JWT attribute set in
|
||||
* `core/utils.py::generate_token` (or the equivalent flag on a lobby
|
||||
* participant). No fingerprints, no email — just a one-glance signal.
|
||||
*/
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiShieldCheckFill, RiUserLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Participant } from 'livekit-client'
|
||||
|
||||
interface BadgeProps {
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
interface FromParticipantProps extends BadgeProps {
|
||||
participant: Participant
|
||||
isAuthenticated?: never
|
||||
}
|
||||
|
||||
interface FromFlagProps extends BadgeProps {
|
||||
isAuthenticated: boolean
|
||||
participant?: never
|
||||
}
|
||||
|
||||
export function IdentityBadge(props: FromParticipantProps | FromFlagProps) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'identity' })
|
||||
const isAuthenticated =
|
||||
props.participant !== undefined
|
||||
? props.participant.attributes?.is_authenticated === 'true'
|
||||
: props.isAuthenticated
|
||||
const px = props.size === 'md' ? 14 : 12
|
||||
|
||||
const label = isAuthenticated ? t('proconnect') : t('anonymous')
|
||||
const color = isAuthenticated ? '#1e40af' : '#b45309'
|
||||
const bg = isAuthenticated ? 'rgba(30,64,175,0.10)' : 'rgba(180,83,9,0.10)'
|
||||
|
||||
return (
|
||||
<span
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.2rem',
|
||||
padding: '0 0.3rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.02em',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
style={{ backgroundColor: bg, color }}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<RiShieldCheckFill size={px} color={color} />
|
||||
) : (
|
||||
<RiUserLine size={px} color={color} />
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Modal asking the admin to confirm that they accept pausing encryption
|
||||
* to start recording or transcription.
|
||||
*/
|
||||
import { Button, Dialog, Text } from '@/primitives'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
reason: 'recording' | 'transcript'
|
||||
onConfirm: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function PauseEncryptionConfirmDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
reason,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'encryption.pauseConfirm',
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title={t(`title.${reason}`)}
|
||||
>
|
||||
<VStack
|
||||
alignItems="start"
|
||||
gap="0.75rem"
|
||||
className={css({ maxWidth: '24rem' })}
|
||||
>
|
||||
<Text variant="sm">{t('description')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
className={css({
|
||||
fontSize: '0.8rem',
|
||||
color: 'greyscale.500',
|
||||
})}
|
||||
>
|
||||
{t('learnMore')}
|
||||
</Text>
|
||||
<HStack gap="0.5rem" justify="end" className={css({ width: '100%' })}>
|
||||
<Button variant="secondary" onPress={() => onOpenChange(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={async () => {
|
||||
await onConfirm()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
{t(`confirm.${reason}`)}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Top-left status banner shown during a meeting.
|
||||
*
|
||||
* Renders a horizontal stack of pills, one per active state:
|
||||
* - "End-to-end encrypted" / "Encryption paused"
|
||||
* - "Recording in progress"
|
||||
* - "Transcription in progress"
|
||||
*
|
||||
* Each pill auto-collapses to its icon a few seconds after appearing,
|
||||
* and expands back on hover.
|
||||
*/
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import {
|
||||
RiFileTextFill,
|
||||
RiLockFill,
|
||||
RiLockUnlockFill,
|
||||
RiRecordCircleFill,
|
||||
} from '@remixicon/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import {
|
||||
RecordingMode,
|
||||
useRecordingStatuses,
|
||||
} from '@/features/recording'
|
||||
|
||||
const COLLAPSE_DELAY_MS = 4000
|
||||
|
||||
interface PillProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
background: string
|
||||
pulse?: boolean
|
||||
}
|
||||
|
||||
function StatusPill({ icon, label, background, pulse }: PillProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setCollapsed(true), COLLAPSE_DELAY_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setCollapsed(false)}
|
||||
onMouseLeave={() => setCollapsed(true)}
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.35rem',
|
||||
padding: '0.3rem 0.6rem',
|
||||
borderRadius: '1rem',
|
||||
border: '2px solid rgba(0, 0, 0, 0.3)',
|
||||
cursor: 'default',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-width 300ms ease, padding-right 200ms ease',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
style={{
|
||||
backgroundColor: background,
|
||||
maxWidth: collapsed ? '2.2rem' : '20rem',
|
||||
paddingRight: collapsed ? '0.3rem' : '0.6rem',
|
||||
animation: pulse ? 'pulse_background 1.6s infinite' : undefined,
|
||||
}}
|
||||
>
|
||||
<span className={css({ flexShrink: 0, display: 'inline-flex' })}>
|
||||
{icon}
|
||||
</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
letterSpacing: '0.02em',
|
||||
transition: 'opacity 200ms ease',
|
||||
})}
|
||||
style={{ opacity: collapsed ? 0 : 1 }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RoomStatusBanner() {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'roomStatus' })
|
||||
const roomData = useRoomData()
|
||||
|
||||
// Use the metadata-driven `isStarted` for both pills — it flips to
|
||||
// false the moment the user clicks stop (recording_status moves to
|
||||
// Saving), so the pill disappears immediately instead of lingering
|
||||
// through LK's 1-2s post-stop callback delay.
|
||||
const screenRec = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
const transcript = useRecordingStatuses(RecordingMode.Transcript)
|
||||
const isRecording = screenRec.isStarted
|
||||
const isTranscribing = transcript.isStarted
|
||||
|
||||
// The encryption pill reflects the room's nature plus its current paused
|
||||
// state — never disappears just because encryption is temporarily off
|
||||
// mid-call.
|
||||
const encryptionCapable = !!roomData?.is_encrypted
|
||||
const encryptionPaused = !!roomData?.encryption_paused
|
||||
|
||||
if (!encryptionCapable && !isRecording && !isTranscribing) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack
|
||||
gap="0.4rem"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '0.5rem',
|
||||
left: '0.5rem',
|
||||
zIndex: 10,
|
||||
})}
|
||||
>
|
||||
{encryptionCapable && !encryptionPaused && (
|
||||
<StatusPill
|
||||
key="encrypted"
|
||||
icon={<RiLockFill size={13} color="white" />}
|
||||
label={t('encrypted')}
|
||||
background="#1e3a5f"
|
||||
/>
|
||||
)}
|
||||
{encryptionCapable && encryptionPaused && (
|
||||
<StatusPill
|
||||
key="paused"
|
||||
icon={<RiLockUnlockFill size={13} color="white" />}
|
||||
label={t('paused')}
|
||||
background="#b45309"
|
||||
/>
|
||||
)}
|
||||
{isTranscribing && (
|
||||
<StatusPill
|
||||
key="transcript"
|
||||
icon={<RiFileTextFill size={13} color="white" />}
|
||||
label={t('transcribing')}
|
||||
background="#7c2d12"
|
||||
/>
|
||||
)}
|
||||
{isRecording && (
|
||||
<StatusPill
|
||||
key="recording"
|
||||
icon={<RiRecordCircleFill size={13} color="white" />}
|
||||
label={t('recording')}
|
||||
background="#b91c1c"
|
||||
pulse
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Small banner shown on top of a SIP/phone participant's tile when the room
|
||||
* is live-encrypted (encrypted AND not paused). The avatar (LiveKit's
|
||||
* ParticipantPlaceholder) stays visible behind it; this is just a callout
|
||||
* near the bottom of the tile so everyone in the room knows why the caller
|
||||
* isn't producing audio/video. Admins get a CTA to pause encryption.
|
||||
*
|
||||
* Detection is kind-then-identity: ParticipantKind.SIP is the canonical
|
||||
* signal but we also accept identities prefixed `sip_` so the UI keeps
|
||||
* working if a gateway revision forgets to set the kind enum.
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Participant, ParticipantKind } from 'livekit-client'
|
||||
import { RiLockFill } from '@remixicon/react'
|
||||
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
|
||||
function isSipParticipant(p: Participant): boolean {
|
||||
if (p.kind === ParticipantKind.SIP) return true
|
||||
return p.identity.startsWith('sip_')
|
||||
}
|
||||
|
||||
interface Props {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export function SipBlockedTileOverlay({ participant }: Props) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.sipBlocked' })
|
||||
const room = useRoomData()
|
||||
const isAdmin = useIsAdminOrOwner()
|
||||
|
||||
const liveEncryption =
|
||||
!!room && !!room.is_encrypted && !room.encryption_paused
|
||||
if (!liveEncryption) return null
|
||||
if (!isSipParticipant(participant)) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '2.5rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.6rem 1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
maxWidth: '85%',
|
||||
zIndex: 4,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
role="status"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
color: '#fbbf24',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<RiLockFill size={14} />
|
||||
<span>{t('title')}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: '#d1d5db',
|
||||
fontSize: '0.75rem',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.4,
|
||||
maxWidth: '22rem',
|
||||
}}
|
||||
>
|
||||
{isAdmin ? t('bodyAdmin') : t('bodyParticipant')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createContext } from 'react'
|
||||
import {
|
||||
EncryptionPhase,
|
||||
EncryptionStatusContextValue,
|
||||
} from './encryptionStatusTypes'
|
||||
|
||||
const noopContext: EncryptionStatusContextValue = {
|
||||
phase: EncryptionPhase.UNENCRYPTED,
|
||||
pausedByMe: false,
|
||||
pauseEncryption: async () => false,
|
||||
resumeEncryption: async () => false,
|
||||
}
|
||||
|
||||
export const EncryptionStatusContext =
|
||||
createContext<EncryptionStatusContextValue>(noopContext)
|
||||
@@ -1,32 +0,0 @@
|
||||
export enum EncryptionPhase {
|
||||
UNENCRYPTED = 'unencrypted',
|
||||
ENCRYPTED = 'encrypted',
|
||||
PAUSED = 'paused',
|
||||
}
|
||||
|
||||
export type PauseReason =
|
||||
| 'recording'
|
||||
| 'transcript'
|
||||
| 'manual'
|
||||
| 'sip_participant'
|
||||
|
||||
export interface EncryptionStatus {
|
||||
phase: EncryptionPhase
|
||||
pauseReason?: PauseReason
|
||||
/** True when the local participant initiated the current pause. */
|
||||
pausedByMe: boolean
|
||||
}
|
||||
|
||||
export interface EncryptionStatusContextValue extends EncryptionStatus {
|
||||
/**
|
||||
* Pause encryption for this session and notify the rest of the room.
|
||||
* Returns true on success.
|
||||
*/
|
||||
pauseEncryption: (reason: PauseReason) => Promise<boolean>
|
||||
/**
|
||||
* Resume encryption after a pause. Returns true on success. Only the
|
||||
* participant who initiated the pause (or anyone meeting the legitimacy
|
||||
* rules) can resume.
|
||||
*/
|
||||
resumeEncryption: () => Promise<boolean>
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export {
|
||||
generatePassphrase,
|
||||
isValidPassphrase,
|
||||
getPassphraseFromHash,
|
||||
PASSPHRASE_LENGTH,
|
||||
} from './passphrase'
|
||||
export { EncryptionStatusProvider } from './EncryptionStatusContext'
|
||||
export { useEncryptionStatus } from './useEncryptionStatus'
|
||||
export { EncryptionPhase } from './encryptionStatusTypes'
|
||||
export type { EncryptionStatus, PauseReason } from './encryptionStatusTypes'
|
||||
export { RoomStatusBanner } from './RoomStatusBanner'
|
||||
export { EncryptionStatusSnackbars } from './EncryptionStatusSnackbars'
|
||||
export { PauseEncryptionConfirmDialog } from './PauseEncryptionConfirmDialog'
|
||||
export { IdentityBadge } from './IdentityBadge'
|
||||
export { EncryptionMismatchScreen } from './EncryptionMismatchScreen'
|
||||
export { EncryptionAutoResumeWatcher } from './EncryptionAutoResumeWatcher'
|
||||
export { SipBlockedTileOverlay } from './SipBlockedTileOverlay'
|
||||
export { DecryptionFailedTileOverlay } from './DecryptionFailedTileOverlay'
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Passphrase utilities for end-to-end encryption.
|
||||
*
|
||||
* The passphrase is appended to a room URL as the hash fragment
|
||||
* (e.g. `https://meet.example.com/abc-defg-hij#<passphrase>`). The
|
||||
* server never sees it; participants share it by sharing the link.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Number of random bytes used to seed a passphrase.
|
||||
* Each byte is rendered as 2 base36 characters, so the resulting
|
||||
* passphrase is 48 characters long.
|
||||
*/
|
||||
const PASSPHRASE_BYTES = 24
|
||||
|
||||
/** Length, in characters, of a generated passphrase. */
|
||||
export const PASSPHRASE_LENGTH = PASSPHRASE_BYTES * 2
|
||||
|
||||
/** Generate a random passphrase suitable for room E2E encryption. */
|
||||
export function generatePassphrase(): string {
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(PASSPHRASE_BYTES)))
|
||||
.map((b) => b.toString(36).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Whether a string looks like a valid passphrase. */
|
||||
export function isValidPassphrase(value: string): boolean {
|
||||
return value.length === PASSPHRASE_LENGTH && /^[a-z0-9]+$/.test(value)
|
||||
}
|
||||
|
||||
/** Read the current URL hash (without the leading `#`). */
|
||||
export function getPassphraseFromHash(): string {
|
||||
return window.location.hash.replace(/^#/, '')
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { useContext } from 'react'
|
||||
import { EncryptionStatusContext } from './encryptionStatusContextValue'
|
||||
|
||||
export const useEncryptionStatus = () => useContext(EncryptionStatusContext)
|
||||
@@ -1,93 +0,0 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { ApiFileItem } from '@/features/files/api/types.ts'
|
||||
import { keys } from '@/api/queryKeys.ts'
|
||||
import { queryClient } from '@/api/queryClient.ts'
|
||||
|
||||
/**
|
||||
* Upload a file, using XHR so we can report on progress through a handler.
|
||||
*
|
||||
* @param url The URL to PUT the file to.
|
||||
* @param file The file to upload.
|
||||
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
|
||||
*/
|
||||
export const uploadFile = (
|
||||
url: string,
|
||||
file: File,
|
||||
progressHandler: (progress: number) => void
|
||||
) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('PUT', url)
|
||||
xhr.setRequestHeader('X-amz-acl', 'private')
|
||||
xhr.setRequestHeader('Content-Type', file.type)
|
||||
|
||||
xhr.addEventListener('error', reject)
|
||||
xhr.addEventListener('abort', reject)
|
||||
|
||||
xhr.addEventListener('readystatechange', () => {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status === 200) {
|
||||
// Make sure to always set the progress to 100% when the upload is done.
|
||||
// Because 'progress' event listener is not called when the file size is 0.
|
||||
progressHandler(100)
|
||||
return resolve(true)
|
||||
}
|
||||
reject(new Error(`Failed to perform the upload on ${url}.`))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.upload.addEventListener('progress', (progressEvent) => {
|
||||
if (progressEvent.lengthComputable) {
|
||||
progressHandler(
|
||||
Math.floor((progressEvent.loaded / progressEvent.total) * 100)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.send(file)
|
||||
})
|
||||
|
||||
/**
|
||||
* Asynchronously creates a new file and uploads it to the server.
|
||||
*
|
||||
* @param {object} params - The parameters for the file creation and upload process.
|
||||
* @param {File} params.file - The file object to be uploaded.
|
||||
* @param {function} params.onProgress - A callback function that receives the upload progress as a number (0 to 100).
|
||||
* @returns {Promise<ApiFileItem>} A promise that resolves when the file has been successfully uploaded and the server process is completed.
|
||||
*/
|
||||
export const createFile = async ({
|
||||
file,
|
||||
onProgress,
|
||||
}: {
|
||||
file: File
|
||||
onProgress: (progress: number) => void
|
||||
}): Promise<ApiFileItem> => {
|
||||
const res = await fetchApi<ApiFileItem>(`/files/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename: file.name, type: 'background_image' }),
|
||||
})
|
||||
if (res.upload_state !== 'pending') {
|
||||
throw new Error('State should be pending right after creation')
|
||||
}
|
||||
const policy = res.policy
|
||||
await uploadFile(policy, file, onProgress)
|
||||
const createdFile = await fetchApi<ApiFileItem>(
|
||||
`/files/${res.id}/upload-ended/`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
)
|
||||
|
||||
// We invalidate the files query to make sure the new file is immediately available.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [keys.files],
|
||||
})
|
||||
return createdFile
|
||||
}
|
||||
|
||||
export const useCreateFile = () => {
|
||||
return useMutation({
|
||||
mutationFn: createFile,
|
||||
})
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys.ts'
|
||||
|
||||
/**
|
||||
* Deletes a file specified by its unique identifier.
|
||||
*
|
||||
* @param {Object} params - The parameters required for deleting the file.
|
||||
* @param {string} params.fileId - The unique identifier of the file to be deleted.
|
||||
* @returns {Promise<void>} A promise that resolves when the file is successfully deleted.
|
||||
*/
|
||||
export const deleteFile = async ({
|
||||
fileId,
|
||||
}: {
|
||||
fileId: string
|
||||
}): Promise<void> => {
|
||||
await fetchApi<void>(`/files/${fileId}/`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteFile = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteFile,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [keys.files],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import {
|
||||
ApiFileItem,
|
||||
ApiFileType,
|
||||
ApiFileUploadState,
|
||||
} from '@/features/files/api/types.ts'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
|
||||
type ListFilesResponse = {
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
results: ApiFileItem[]
|
||||
}
|
||||
|
||||
type ListFilesFilters = {
|
||||
is_creator_me?: boolean
|
||||
type?: ApiFileType
|
||||
upload_state?: ApiFileUploadState
|
||||
is_deleted?: boolean
|
||||
}
|
||||
|
||||
export type ListFilesParams = {
|
||||
filters?: ListFilesFilters
|
||||
pagination: {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
}
|
||||
|
||||
export const listMyFiles = async ({
|
||||
filters = {},
|
||||
pagination: { page, pageSize },
|
||||
}: ListFilesParams): Promise<ListFilesResponse> => {
|
||||
const query = new URLSearchParams()
|
||||
query.append('page', page.toString())
|
||||
query.append('page_size', pageSize.toString())
|
||||
if (filters?.is_creator_me ?? true) {
|
||||
query.append('is_creator_me', 'true')
|
||||
}
|
||||
if (filters?.type) {
|
||||
query.append('type', filters.type)
|
||||
}
|
||||
if (filters?.upload_state) {
|
||||
query.append('upload_state', filters.upload_state)
|
||||
}
|
||||
if (typeof filters?.is_deleted === 'boolean') {
|
||||
query.append('is_deleted', filters.is_deleted ? 'true' : 'false')
|
||||
}
|
||||
|
||||
return fetchApi<ListFilesResponse>(`/files?${query.toString()}`, {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export const useListMyFiles = (params: Parameters<typeof listMyFiles>[0]) => {
|
||||
const { isLoggedIn } = useUser()
|
||||
const { data: appConfig } = useConfig()
|
||||
return useQuery({
|
||||
queryKey: [keys.files, params],
|
||||
queryFn: () => listMyFiles(params),
|
||||
refetchOnMount: 'always',
|
||||
placeholderData: keepPreviousData,
|
||||
enabled:
|
||||
isLoggedIn && appConfig?.background_image?.upload_is_enabled === true,
|
||||
})
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
export type ApiFileCreator = {
|
||||
id: string // UUID
|
||||
full_name: string | null
|
||||
short_name: string | null
|
||||
}
|
||||
|
||||
export type ApiFileType = 'background_image'
|
||||
export type ApiFileUploadState = 'pending' | 'ready'
|
||||
|
||||
export type ApiFileItem = {
|
||||
id: string // UUID
|
||||
created_at: string // ISO datetime string
|
||||
updated_at: string // ISO datetime string
|
||||
title: string
|
||||
type: ApiFileType
|
||||
creator: ApiFileCreator
|
||||
deleted_at: string | null
|
||||
hard_deleted_at: string | null
|
||||
filename: string
|
||||
upload_state: ApiFileUploadState
|
||||
mimetype: string // e.g. "image/png"
|
||||
size: number // file size in bytes
|
||||
description: string | null
|
||||
} & (
|
||||
| {
|
||||
upload_state: 'ready'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
upload_state: 'pending'
|
||||
policy: string
|
||||
url: null
|
||||
}
|
||||
)
|
||||
@@ -1,146 +1,35 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { isRoomValid } from '@/features/rooms'
|
||||
import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
|
||||
export const JoinMeetingDialog = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const [step, setStep] = useState<'room' | 'passphrase'>('room')
|
||||
const [roomId, setRoomId] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const parseInput = (input: string): { roomId: string; hash: string } => {
|
||||
const trimmed = input.trim()
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
const id = url.pathname.replace(/^\//, '')
|
||||
return { roomId: id, hash: url.hash.slice(1) }
|
||||
} catch {
|
||||
// Not a URL — treat as room code, normalize (add hyphens if 10 chars)
|
||||
const raw = trimmed.replace(`${window.location.origin}/`, '')
|
||||
return { roomId: normalizeRoomId(raw), hash: '' }
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoomSubmit = async (data: { roomId?: FormDataEntryValue }) => {
|
||||
const input = data.roomId as string
|
||||
const parsed = parseInput(input)
|
||||
|
||||
if (parsed.hash) {
|
||||
navigateTo('room', parsed.roomId)
|
||||
window.location.hash = parsed.hash
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const room = await fetchRoom({ roomId: parsed.roomId })
|
||||
if (room.is_encrypted) {
|
||||
setRoomId(parsed.roomId)
|
||||
setStep('passphrase')
|
||||
return
|
||||
}
|
||||
navigateTo('room', parsed.roomId)
|
||||
} catch {
|
||||
// Room doesn't exist yet or error — navigate anyway
|
||||
navigateTo('room', parsed.roomId)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePassphraseSubmit = (data: { passphrase?: FormDataEntryValue }) => {
|
||||
const passphrase = (data.passphrase as string).trim()
|
||||
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
|
||||
const roomId = (data.roomId as string)
|
||||
.trim()
|
||||
.replace(`${window.location.origin}/`, '')
|
||||
navigateTo('room', roomId)
|
||||
window.location.hash = passphrase
|
||||
}
|
||||
|
||||
const validateRoomId = (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const { roomId: id } = parseInput(trimmed)
|
||||
return !isRoomValid(id) ? (
|
||||
return !isRoomValid(trimmed) ? (
|
||||
<>
|
||||
<p>{t('joinInputError')}</p>
|
||||
<Ul>
|
||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||
<li>uio-azer-jkl</li>
|
||||
<li>uioazerjkl</li>
|
||||
</Ul>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
|
||||
if (step === 'passphrase') {
|
||||
return (
|
||||
<Dialog title={t('joinMeeting')}>
|
||||
<Form onSubmit={handlePassphraseSubmit} submitLabel={t('joinPassphraseSubmit')}>
|
||||
<P
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('joinPassphraseDescription', {
|
||||
interpolation: { escapeValue: false },
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'greyscale.100',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
marginBottom: '1rem',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all',
|
||||
lineHeight: '1.5',
|
||||
border: '1px solid',
|
||||
borderColor: 'greyscale.200',
|
||||
'& strong': {
|
||||
color: '#16a34a',
|
||||
fontWeight: 700,
|
||||
},
|
||||
})}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('joinPassphraseExample', {
|
||||
origin: window.location.origin,
|
||||
interpolation: { escapeValue: false },
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* eslint-disable jsx-a11y/no-autofocus */}
|
||||
<Field
|
||||
type="text"
|
||||
autoFocus
|
||||
isRequired
|
||||
name="passphrase"
|
||||
label={t('joinPassphraseLabel')}
|
||||
validate={(value: string) =>
|
||||
!value ? t('joinPassphraseError') : null
|
||||
}
|
||||
/>
|
||||
|
||||
<P
|
||||
className={css({
|
||||
fontSize: '0.8rem',
|
||||
color: '#b45309',
|
||||
marginTop: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{t('joinPassphraseWarning')}
|
||||
</P>
|
||||
</Form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={t('joinMeeting')}>
|
||||
<Form onSubmit={handleRoomSubmit} submitLabel={isLoading ? '...' : t('joinInputSubmit')}>
|
||||
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
|
||||
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
|
||||
<Field
|
||||
type="text"
|
||||
|
||||
@@ -13,12 +13,11 @@ import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRo
|
||||
// fixme - duplication with the InviteDialog
|
||||
export const LaterMeetingDialog = ({
|
||||
room,
|
||||
hash,
|
||||
...dialogProps
|
||||
}: { room: null | ApiRoom; hash?: string } & Omit<DialogProps, 'title'>) => {
|
||||
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
||||
|
||||
const roomUrl = room ? `${getRouteUrl('room', room.slug)}${hash ? `#${hash}` : ''}` : null
|
||||
const roomUrl = room && getRouteUrl('room', room?.slug)
|
||||
const telephony = useTelephony()
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
@@ -32,7 +31,7 @@ export const LaterMeetingDialog = ({
|
||||
copyRoomToClipboard,
|
||||
isRoomUrlCopied,
|
||||
copyRoomUrlToClipboard,
|
||||
} = useCopyRoomToClipboard(room || undefined, hash)
|
||||
} = useCopyRoomToClipboard(room || undefined)
|
||||
|
||||
return (
|
||||
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useUser, UserAware } from '@/features/auth'
|
||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { generatePassphrase } from '@/features/encryption'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
@@ -149,27 +148,17 @@ const IntroText = styled('div', {
|
||||
|
||||
export const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { isLoggedIn, user } = useUser()
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
const {
|
||||
userChoices: { username },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom; hash?: string }>(null)
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||
|
||||
const { data } = useConfig()
|
||||
const encryptionAvailable = !!data?.encryption?.enabled
|
||||
const defaultEncryption = encryptionAvailable && !!user?.default_encryption
|
||||
|
||||
const buildRoomBundle = async () => {
|
||||
const slug = generateRoomId()
|
||||
const isEncrypted = defaultEncryption
|
||||
const hash = isEncrypted ? generatePassphrase() : undefined
|
||||
const room = await createRoom({ slug, username, isEncrypted })
|
||||
return { room, hash }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const checkSiteAndRedirect = async () => {
|
||||
@@ -221,17 +210,12 @@ export const Home = () => {
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const { room, hash } = await buildRoomBundle()
|
||||
navigateTo('room', room.slug, {
|
||||
state: { create: true, initialRoomData: room },
|
||||
})
|
||||
if (hash) {
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}#${hash}`
|
||||
)
|
||||
}
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
@@ -242,9 +226,11 @@ export const Home = () => {
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const { room, hash } = await buildRoomBundle()
|
||||
setLaterRoom({ room, hash })
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
@@ -279,8 +265,7 @@ export const Home = () => {
|
||||
</RightColumn>
|
||||
</Columns>
|
||||
<LaterMeetingDialog
|
||||
room={laterRoom?.room ?? null}
|
||||
hash={laterRoom?.hash}
|
||||
room={laterRoom}
|
||||
onOpenChange={() => setLaterRoom(null)}
|
||||
/>
|
||||
</Screen>
|
||||
|
||||
+6
-2
@@ -34,6 +34,7 @@ export const WaitingParticipantNotification = () => {
|
||||
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
||||
|
||||
useEffect(() => {
|
||||
// Show notification when the first participant enters the waiting room
|
||||
if (
|
||||
!isParticipantListEmpty(waitingParticipants) &&
|
||||
isParticipantListEmpty(prevWaitingParticipant) &&
|
||||
@@ -48,9 +49,10 @@ export const WaitingParticipantNotification = () => {
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowQuickActionsMessage(false)
|
||||
timerRef.current = null
|
||||
timerRef.current = null // Clear the ref when timeout completes
|
||||
}, NOTIFICATION_DISPLAY_DURATION)
|
||||
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
|
||||
// Hide notification when the participant count changes
|
||||
setShowQuickActionsMessage(false)
|
||||
}
|
||||
}, [
|
||||
@@ -61,6 +63,7 @@ export const WaitingParticipantNotification = () => {
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
// This cleanup function will only run when the component unmounts
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
@@ -69,6 +72,7 @@ export const WaitingParticipantNotification = () => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Hide notification when participants panel is opened
|
||||
if (isParticipantsOpen) {
|
||||
setShowQuickActionsMessage(false)
|
||||
}
|
||||
@@ -96,7 +100,7 @@ export const WaitingParticipantNotification = () => {
|
||||
>
|
||||
{t('one')}
|
||||
</Text>
|
||||
<HStack gap="0.5rem">
|
||||
<HStack gap="1rem">
|
||||
<Avatar
|
||||
name={waitingParticipants[0].username}
|
||||
bgColor={waitingParticipants[0].color}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
||||
import { RecordingStateToast } from './RecordingStateToast'
|
||||
import { ErrorAlertDialog } from './ErrorAlertDialog'
|
||||
|
||||
// RecordingStateToast removed — the RoomStatusBanner (top-left pill row)
|
||||
// now shows "Recording in progress" and "Transcription in progress" in the
|
||||
// same place, so the standalone toast was rendering behind the new pills.
|
||||
|
||||
export const RecordingProvider = () => {
|
||||
return (
|
||||
<>
|
||||
<RecordingStateToast />
|
||||
<LimitReachedAlertDialog />
|
||||
<ErrorAlertDialog />
|
||||
</>
|
||||
|
||||
@@ -17,8 +17,6 @@ export type ApiRoom = {
|
||||
pin_code: string
|
||||
is_administrable: boolean
|
||||
access_level: ApiAccessLevel
|
||||
is_encrypted: boolean
|
||||
encryption_paused: boolean
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: {
|
||||
[key: string]: string | number | boolean | string[]
|
||||
|
||||
@@ -7,22 +7,18 @@ export interface CreateRoomParams {
|
||||
slug: string
|
||||
callbackId?: string
|
||||
username?: string
|
||||
isEncrypted?: boolean
|
||||
}
|
||||
|
||||
const createRoom = ({
|
||||
slug,
|
||||
callbackId,
|
||||
username = '',
|
||||
isEncrypted = false,
|
||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
const queryParams = username ? `?username=${encodeURIComponent(username)}` : ''
|
||||
return fetchApi(`rooms/${queryParams}`, {
|
||||
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
callback_id: callbackId,
|
||||
is_encrypted: isEncrypted,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ export type WaitingParticipant = {
|
||||
status: string
|
||||
username: string
|
||||
color: string
|
||||
is_authenticated: boolean
|
||||
}
|
||||
|
||||
export type WaitingParticipantsResponse = {
|
||||
|
||||
@@ -5,9 +5,7 @@ import { ApiError } from '@/api/ApiError'
|
||||
|
||||
export type PatchRoomParams = {
|
||||
roomId: string
|
||||
room: Partial<
|
||||
Pick<ApiRoom, 'configuration' | 'access_level' | 'encryption_paused'>
|
||||
>
|
||||
room: Partial<Pick<ApiRoom, 'configuration' | 'access_level'>>
|
||||
}
|
||||
|
||||
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
@@ -7,21 +7,11 @@ import {
|
||||
} from '@livekit/components-react'
|
||||
import {
|
||||
DisconnectReason,
|
||||
ExternalE2EEKeyProvider,
|
||||
MediaDeviceFailure,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomOptions,
|
||||
VideoPresets,
|
||||
} from 'livekit-client'
|
||||
import {
|
||||
generatePassphrase,
|
||||
getPassphraseFromHash,
|
||||
isValidPassphrase,
|
||||
EncryptionStatusProvider,
|
||||
EncryptionMismatchScreen,
|
||||
EncryptionPhase,
|
||||
} from '@/features/encryption'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
@@ -30,7 +20,6 @@ import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { fetchRoom } from '../api/fetchRoom'
|
||||
import { ApiRoom } from '../api/ApiRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { usePatchRoom } from '../api/patchRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -97,85 +86,12 @@ export const Conference = ({
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// Trust the URL hash for the runtime "is encrypted" decision: it's the
|
||||
// only signal a hacked server can't fabricate. The DB flag tells us
|
||||
// whether the room creator *meant* this room to be encrypted — it's used
|
||||
// to detect mismatches (see below) but never to enable encryption alone.
|
||||
// encryption_paused is an admin override on an encrypted room: when set,
|
||||
// E2EE is suspended for this call so external devices can join, but the
|
||||
// link still carries the hash and the room can be resumed at any time.
|
||||
//
|
||||
// Two derived flags:
|
||||
// encryptionCapable — this room has an encryption key; the Room object
|
||||
// is constructed with the e2ee worker + key provider regardless of
|
||||
// whether E2EE is currently active. Stable across mid-call pauses, so
|
||||
// the Room instance never gets recreated mid-call.
|
||||
// liveEncryption — encryption is currently active (capable AND not
|
||||
// paused). Drives room.setE2EEEnabled() and the encryption phase UI.
|
||||
const hashPassphrase = getPassphraseFromHash()
|
||||
const dbSaysEncrypted = !!data?.is_encrypted
|
||||
const isPaused = !!data?.encryption_paused
|
||||
const hasValidHash = isValidPassphrase(hashPassphrase)
|
||||
|
||||
const encryptionMismatch:
|
||||
| 'missingPassphrase'
|
||||
| 'unexpectedPassphrase'
|
||||
| null =
|
||||
data === undefined
|
||||
? null
|
||||
: dbSaysEncrypted && !isPaused && !hasValidHash
|
||||
? 'missingPassphrase'
|
||||
: !dbSaysEncrypted && hashPassphrase.length > 0
|
||||
? 'unexpectedPassphrase'
|
||||
: null
|
||||
|
||||
const encryptionCapable = dbSaysEncrypted && hasValidHash
|
||||
const liveEncryption = encryptionCapable && !isPaused
|
||||
// Kept as `isEncrypted` so legacy reads below stay readable. Refers to
|
||||
// the live state — flip-on-pause/flip-on-resume is wired below.
|
||||
const isEncrypted = liveEncryption
|
||||
|
||||
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
|
||||
const workerRef = useRef<Worker | null>(null)
|
||||
// Setup is complete once the key provider is wired up; it does NOT need
|
||||
// to re-run when encryption_paused flips. We toggle the active state of
|
||||
// E2EE separately via room.setE2EEEnabled.
|
||||
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(
|
||||
!encryptionCapable
|
||||
)
|
||||
|
||||
const getKeyProvider = () => {
|
||||
if (!keyProviderRef.current && encryptionCapable) {
|
||||
keyProviderRef.current = new ExternalE2EEKeyProvider()
|
||||
}
|
||||
return keyProviderRef.current
|
||||
}
|
||||
|
||||
const getWorker = () => {
|
||||
if (
|
||||
!workerRef.current &&
|
||||
encryptionCapable &&
|
||||
typeof window !== 'undefined'
|
||||
) {
|
||||
workerRef.current = new Worker(
|
||||
new URL('livekit-client/e2ee-worker', import.meta.url)
|
||||
)
|
||||
}
|
||||
return workerRef.current
|
||||
}
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
const baseOptions: RoomOptions = {
|
||||
return {
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
publishDefaults: {
|
||||
// VP8 whenever the room is encryption-capable: encryption_paused
|
||||
// can flip mid-call to let a SIP/phone caller bridge, and the
|
||||
// gateway's room→SIP GStreamer path only handles VP8 today. Using
|
||||
// VP8 unconditionally for encryption-capable rooms keeps the Room
|
||||
// instance stable across pause/resume.
|
||||
videoCodec: encryptionCapable ? 'vp8' : 'vp8',
|
||||
red: !encryptionCapable,
|
||||
videoCodec: 'vp9',
|
||||
},
|
||||
videoCaptureDefaults: {
|
||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||
@@ -190,25 +106,8 @@ export const Conference = ({
|
||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||
},
|
||||
}
|
||||
|
||||
// Always wire up the E2EE worker + key provider for an encryption-
|
||||
// capable room. We toggle whether encryption is *active* with
|
||||
// room.setE2EEEnabled below; the worker stays around so resume is a
|
||||
// single API call rather than a Room reconstruction.
|
||||
if (encryptionCapable) {
|
||||
const worker = getWorker()
|
||||
const keyProvider = getKeyProvider()
|
||||
if (keyProvider && worker) {
|
||||
baseOptions.encryption = { keyProvider, worker }
|
||||
}
|
||||
}
|
||||
|
||||
return baseOptions
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
// getKeyProvider/getWorker are stable refs, intentionally not in deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
encryptionCapable,
|
||||
userConfig.videoDeviceId,
|
||||
userConfig.videoPublishResolution,
|
||||
userConfig.audioDeviceId,
|
||||
@@ -217,6 +116,62 @@ export const Conference = ({
|
||||
|
||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room
|
||||
* This prefetch helps reduce initial connection latency by establishing
|
||||
* an early HTTP connection to the WebRTC signaling server
|
||||
*
|
||||
* It should cache DNS and TLS keys.
|
||||
*/
|
||||
const prepareConnection = async () => {
|
||||
if (!apiConfig || isConnectionWarmedUp) return
|
||||
await room.prepareConnection(apiConfig.livekit.url)
|
||||
|
||||
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
apiConfig.livekit.url
|
||||
.replace('https://', 'wss://')
|
||||
.replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND:
|
||||
*
|
||||
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
||||
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
||||
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
|
||||
*
|
||||
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
||||
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
||||
*
|
||||
* Note: This issue is reproducible on LiveKit's demo app.
|
||||
* Reference: livekit-examples/meet/issues/466
|
||||
*/
|
||||
const ws = new WebSocket(wssUrl)
|
||||
// 401 unauthorized response is expected
|
||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||
} catch (e) {
|
||||
console.debug('Firefox WebSocket workaround failed.', e)
|
||||
}
|
||||
}
|
||||
|
||||
setIsConnectionWarmedUp(true)
|
||||
}
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
error: MediaDeviceFailure | null
|
||||
kind: MediaDeviceKind | null
|
||||
}>({
|
||||
error: null,
|
||||
kind: null,
|
||||
})
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
/*
|
||||
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
||||
@@ -231,198 +186,9 @@ export const Conference = ({
|
||||
return livekit_url
|
||||
}, [apiConfig?.livekit])
|
||||
|
||||
const isAdmin = mode === 'create' || data?.is_administrable === true
|
||||
const adminPassphraseRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!encryptionCapable || encryptionSetupComplete) return
|
||||
|
||||
const keyProvider = getKeyProvider()
|
||||
if (!keyProvider) return
|
||||
|
||||
let passphrase: string | null = null
|
||||
|
||||
if (isAdmin) {
|
||||
if (!adminPassphraseRef.current) {
|
||||
const existingHash = getPassphraseFromHash()
|
||||
if (existingHash) {
|
||||
adminPassphraseRef.current = existingHash
|
||||
} else {
|
||||
adminPassphraseRef.current = generatePassphrase()
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}#${adminPassphraseRef.current}`
|
||||
)
|
||||
}
|
||||
}
|
||||
passphrase = adminPassphraseRef.current
|
||||
} else {
|
||||
passphrase = getPassphraseFromHash() || null
|
||||
}
|
||||
|
||||
if (!passphrase) {
|
||||
console.error('[Encryption] No passphrase available')
|
||||
return
|
||||
}
|
||||
|
||||
keyProvider
|
||||
.setKey(passphrase)
|
||||
.then(async () => {
|
||||
// Enable E2EE BEFORE connecting — sets encryptionType=GCM so tracks
|
||||
// are published with encryption metadata from the start. If the
|
||||
// room is currently paused, we set up the worker but leave E2EE
|
||||
// disabled (resume flips it on without rebuilding the Room).
|
||||
try {
|
||||
await room.setE2EEEnabled(liveEncryption)
|
||||
} catch (err) {
|
||||
console.error('[Encryption] E2EE enable failed:', err)
|
||||
}
|
||||
|
||||
setEncryptionSetupComplete(true)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Encryption] Key setup failed:', err)
|
||||
})
|
||||
// getKeyProvider is a stable ref; not part of deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room, encryptionCapable, encryptionSetupComplete, isAdmin])
|
||||
|
||||
// Mid-call admin toggle: when the server flips encryption_paused, every
|
||||
// browser client sees the change as `liveEncryption` flipping. A single
|
||||
// setE2EEEnabled call is enough — livekit-client internally calls
|
||||
// republishAllTracks so the SFU forwards tracks in the new mode (plain
|
||||
// during pause so SIP can bridge; E2EE on resume).
|
||||
//
|
||||
// republishAllTracks occasionally drops a track silently when its
|
||||
// renegotiation hits a transport-state race. We snapshot the local
|
||||
// mic/camera enabled state before the toggle and re-assert them
|
||||
// afterwards so the user doesn't lose their camera or microphone over a
|
||||
// pause/resume cycle.
|
||||
const prevLiveRef = useRef<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
if (!encryptionCapable || !encryptionSetupComplete) return
|
||||
if (prevLiveRef.current === null) {
|
||||
prevLiveRef.current = liveEncryption
|
||||
return // initial setup already covered this value
|
||||
}
|
||||
if (prevLiveRef.current === liveEncryption) return
|
||||
prevLiveRef.current = liveEncryption
|
||||
void (async () => {
|
||||
const lp = room.localParticipant
|
||||
const camWasOn = lp.isCameraEnabled
|
||||
const micWasOn = lp.isMicrophoneEnabled
|
||||
try {
|
||||
await room.setE2EEEnabled(liveEncryption)
|
||||
} catch (err) {
|
||||
console.error('[Encryption] mid-call E2EE toggle failed', err)
|
||||
return
|
||||
}
|
||||
// Re-assert track state — republishAllTracks may have silently
|
||||
// dropped one of them mid-renegotiation.
|
||||
try {
|
||||
if (camWasOn && !lp.isCameraEnabled) {
|
||||
await lp.setCameraEnabled(true)
|
||||
}
|
||||
if (micWasOn && !lp.isMicrophoneEnabled) {
|
||||
await lp.setMicrophoneEnabled(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Encryption] track re-assert failed', err)
|
||||
}
|
||||
})()
|
||||
}, [room, encryptionCapable, encryptionSetupComplete, liveEncryption])
|
||||
|
||||
// Listen for server-driven metadata updates (admin pausing/resuming from
|
||||
// a different client) and refresh the room query so liveEncryption above
|
||||
// reflects the new state.
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
const onMetadataChanged = () => {
|
||||
queryClient.invalidateQueries({ queryKey: fetchKey })
|
||||
}
|
||||
room.on(RoomEvent.RoomMetadataChanged, onMetadataChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RoomMetadataChanged, onMetadataChanged)
|
||||
}
|
||||
// fetchKey is derived from roomId; both stable
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room])
|
||||
|
||||
// If the user changes the hash mid-session (e.g. corrects a typo), reload
|
||||
// so the new passphrase is picked up by the encryption setup.
|
||||
useEffect(() => {
|
||||
if (!encryptionCapable) return
|
||||
const handleHashChange = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
window.addEventListener('hashchange', handleHashChange)
|
||||
return () => window.removeEventListener('hashchange', handleHashChange)
|
||||
}, [encryptionCapable])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room
|
||||
*/
|
||||
const prepareConnection = async () => {
|
||||
if (!apiConfig || isConnectionWarmedUp) return
|
||||
await room.prepareConnection(apiConfig.livekit.url)
|
||||
|
||||
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
apiConfig.livekit.url
|
||||
.replace('https://', 'wss://')
|
||||
.replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND — see livekit-examples/meet/issues/466
|
||||
*/
|
||||
const ws = new WebSocket(wssUrl)
|
||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||
} catch (e) {
|
||||
console.debug('Firefox WebSocket workaround failed.', e)
|
||||
}
|
||||
}
|
||||
|
||||
setIsConnectionWarmedUp(true)
|
||||
}
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
const setServerEncryptionPaused = async (paused: boolean) => {
|
||||
await patchRoom({ roomId, room: { encryption_paused: paused } })
|
||||
}
|
||||
|
||||
const handlePhaseChange = (phase: EncryptionPhase) => {
|
||||
if (!encryptionCapable) return
|
||||
if (phase === EncryptionPhase.PAUSED) {
|
||||
void room.setE2EEEnabled(false).catch((err) => {
|
||||
console.error('[Encryption] E2EE pause failed', err)
|
||||
})
|
||||
} else if (phase === EncryptionPhase.ENCRYPTED) {
|
||||
// Resume path: re-enable E2EE with the same URL passphrase that's
|
||||
// already loaded into the keyProvider.
|
||||
void room.setE2EEEnabled(true).catch((err) => {
|
||||
console.error('[Encryption] E2EE resume failed', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
error: MediaDeviceFailure | null
|
||||
kind: MediaDeviceKind | null
|
||||
}>({
|
||||
error: null,
|
||||
kind: null,
|
||||
})
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const { t } = useTranslation('rooms')
|
||||
if (isCreateError) {
|
||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={t('error.createRoom.heading')}
|
||||
@@ -431,14 +197,11 @@ export const Conference = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (encryptionMismatch) {
|
||||
return <EncryptionMismatchScreen reason={encryptionMismatch} />
|
||||
}
|
||||
|
||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
||||
// These settings help ensure successful connections in poor network conditions
|
||||
const connectOptions = {
|
||||
maxRetries: 5,
|
||||
peerConnectionTimeout: 60000,
|
||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -448,12 +211,12 @@ export const Conference = ({
|
||||
room={room}
|
||||
serverUrl={serverUrl}
|
||||
token={data?.livekit?.token}
|
||||
connect={isConnectionWarmedUp && encryptionSetupComplete}
|
||||
connect={isConnectionWarmedUp}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={
|
||||
userConfig.videoEnabled && {
|
||||
processor: BackgroundProcessorFactory.fromProcessorConfig(
|
||||
userConfig.processorConfig
|
||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
||||
userConfig.processorSerialized
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -487,13 +250,7 @@ export const Conference = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EncryptionStatusProvider
|
||||
isEncrypted={isEncrypted}
|
||||
onPhaseChange={handlePhaseChange}
|
||||
setServerEncryptionPaused={setServerEncryptionPaused}
|
||||
>
|
||||
<VideoConference />
|
||||
</EncryptionStatusProvider>
|
||||
<VideoConference />
|
||||
{showInviteDialog && !isMobile && (
|
||||
<InviteDialog
|
||||
isOpen={showInviteDialog}
|
||||
|
||||
@@ -43,11 +43,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||
|
||||
const roomData = useRoomData()
|
||||
const baseRoomUrl = getRouteUrl('room', roomData?.slug)
|
||||
// Include the hash (passphrase) for basic encrypted rooms so the full link is visible
|
||||
const roomUrl = window.location.hash
|
||||
? `${baseRoomUrl}${window.location.hash}`
|
||||
: baseRoomUrl
|
||||
const roomUrl = getRouteUrl('room', roomData?.slug)
|
||||
|
||||
const telephony = useTelephony()
|
||||
|
||||
|
||||
@@ -4,15 +4,15 @@ import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createLocalAudioTrack,
|
||||
createLocalVideoTrack,
|
||||
createLocalAudioTrack,
|
||||
LocalAudioTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
} from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { Field } from '@/primitives/Field'
|
||||
import { Button, Dialog, Form, Text } from '@/primitives'
|
||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
@@ -33,13 +33,7 @@ import { queryClient } from '@/api/queryClient'
|
||||
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import {
|
||||
isValidPassphrase,
|
||||
getPassphraseFromHash,
|
||||
EncryptionMismatchScreen,
|
||||
} from '@/features/encryption'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { RiInformationLine } from '@remixicon/react'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
@@ -50,7 +44,8 @@ const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
const Effects = ({
|
||||
videoTrack,
|
||||
}: Pick<EffectsConfigurationProps, 'videoTrack'>) => {
|
||||
onSubmit,
|
||||
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const openDialog = () => setIsDialogOpen(true)
|
||||
@@ -86,7 +81,7 @@ const Effects = ({
|
||||
>
|
||||
{t('subTitle')}
|
||||
</Text>
|
||||
<EffectsConfiguration videoTrack={videoTrack} />
|
||||
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
||||
</Dialog>
|
||||
<Button
|
||||
variant="whiteCircle"
|
||||
@@ -109,29 +104,6 @@ export const Join = ({
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
// Early fetch to inspect the room (encrypted? requires passphrase?)
|
||||
const { data: roomInfo } = useQuery({
|
||||
queryKey: [keys.room, roomId, 'info'],
|
||||
queryFn: () => fetchRoom({ roomId }),
|
||||
staleTime: 6 * 60 * 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isEncryptedRoom = !!roomInfo?.is_encrypted
|
||||
const isPaused = !!roomInfo?.encryption_paused
|
||||
// Live encryption is what gates the "needs a passphrase" UX. A paused
|
||||
// encrypted room accepts hashless joiners (they join in plaintext) and
|
||||
// re-enforces the passphrase requirement once the admin resumes.
|
||||
const liveEncryption = isEncryptedRoom && !isPaused
|
||||
const passphrase = getPassphraseFromHash()
|
||||
const hasValidPassphrase = liveEncryption
|
||||
? isValidPassphrase(passphrase)
|
||||
: true
|
||||
// If the URL has a passphrase but the room itself is not encrypted, the
|
||||
// link looks tampered with — refuse to join and offer a fresh room.
|
||||
const unexpectedPassphrase =
|
||||
!!roomInfo && !roomInfo.is_encrypted && passphrase.length > 0
|
||||
|
||||
const {
|
||||
userChoices: {
|
||||
audioEnabled,
|
||||
@@ -139,7 +111,7 @@ export const Join = ({
|
||||
audioDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
processorSerialized,
|
||||
username,
|
||||
},
|
||||
saveAudioInputEnabled,
|
||||
@@ -148,6 +120,7 @@ export const Join = ({
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
saveUsername,
|
||||
saveProcessorSerialized,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||
@@ -159,7 +132,7 @@ export const Join = ({
|
||||
audioDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
processorSerialized,
|
||||
username,
|
||||
}
|
||||
}
|
||||
@@ -173,8 +146,8 @@ export const Join = ({
|
||||
video: !!initialUserChoices.current &&
|
||||
initialUserChoices.current?.videoEnabled && {
|
||||
deviceId: initialUserChoices.current.videoDeviceId,
|
||||
processor: BackgroundProcessorFactory.fromProcessorConfig(
|
||||
initialUserChoices.current.processorConfig
|
||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
||||
initialUserChoices.current.processorSerialized
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -202,13 +175,20 @@ export const Join = ({
|
||||
[tracks]
|
||||
)
|
||||
|
||||
/*
|
||||
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
|
||||
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
|
||||
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const createVideoTrack = async () => {
|
||||
try {
|
||||
const track = await createLocalVideoTrack({
|
||||
deviceId: { exact: videoDeviceId },
|
||||
processor:
|
||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||
BackgroundProcessorFactory.deserializeProcessor(
|
||||
processorSerialized
|
||||
),
|
||||
})
|
||||
setDynamicVideoTrack(track)
|
||||
} catch (error) {
|
||||
@@ -227,7 +207,7 @@ export const Join = ({
|
||||
}, [
|
||||
videoEnabled,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
processorSerialized,
|
||||
previewVideoTrack,
|
||||
dynamicVideoTrack,
|
||||
])
|
||||
@@ -269,6 +249,8 @@ export const Join = ({
|
||||
const videoTrack = dynamicVideoTrack || previewVideoTrack
|
||||
const audioTrack = dynamicAudioTrack || previewAudioTrack
|
||||
|
||||
// LiveKit by default populates device choices with "default" value.
|
||||
// Instead, use the current device id used by the preview track as a default
|
||||
useResolveInitiallyDefaultDeviceId(
|
||||
audioDeviceId,
|
||||
audioTrack,
|
||||
@@ -308,6 +290,12 @@ export const Join = ({
|
||||
}
|
||||
}, [videoTrack, videoEnabled])
|
||||
|
||||
// Room data strategy:
|
||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
||||
// 3. State is manually updated via queryClient when a waiting participant is accepted
|
||||
// 4. No automatic refetching or revalidation occurs during this period
|
||||
// todo - refactor in a hook
|
||||
const {
|
||||
data: roomData,
|
||||
error,
|
||||
@@ -349,6 +337,7 @@ export const Join = ({
|
||||
const { data } = await refetchRoom()
|
||||
|
||||
if (!data?.livekit) {
|
||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
||||
if (data?.access_level == ApiAccessLevel.TRUSTED) {
|
||||
openLoginHint()
|
||||
}
|
||||
@@ -441,12 +430,6 @@ export const Join = ({
|
||||
)
|
||||
|
||||
default:
|
||||
if (unexpectedPassphrase) {
|
||||
return <EncryptionMismatchScreen reason="unexpectedPassphrase" />
|
||||
}
|
||||
if (liveEncryption && !hasValidPassphrase) {
|
||||
return <EncryptionMismatchScreen reason="missingPassphrase" />
|
||||
}
|
||||
return (
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -473,21 +456,6 @@ export const Join = ({
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>
|
||||
{liveEncryption && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
paddingTop: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RiInformationLine size={14} color="#1e3a5f" />
|
||||
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
|
||||
{t('encryptedHint')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
</Form>
|
||||
)
|
||||
@@ -722,7 +690,12 @@ export const Join = ({
|
||||
zIndex: '1',
|
||||
})}
|
||||
>
|
||||
<Effects videoTrack={videoTrack} />
|
||||
<Effects
|
||||
videoTrack={videoTrack}
|
||||
onSubmit={(processor) =>
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ export const useLobby = ({
|
||||
}) => {
|
||||
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
|
||||
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const clearWaitingTimeout = useCallback(() => {
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
@@ -39,7 +40,10 @@ export const useLobby = ({
|
||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||
queryKey: [keys.requestEntry, roomId],
|
||||
queryFn: async () => {
|
||||
const response = await requestEntry({ roomId, username })
|
||||
const response = await requestEntry({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||
@@ -56,7 +60,7 @@ export const useLobby = ({
|
||||
enabled: status === ApiLobbyStatus.WAITING,
|
||||
})
|
||||
|
||||
const startWaiting = useCallback(async () => {
|
||||
const startWaiting = useCallback(() => {
|
||||
setStatus(ApiLobbyStatus.WAITING)
|
||||
startWaitingTimeout()
|
||||
}, [startWaitingTimeout])
|
||||
|
||||
@@ -62,7 +62,7 @@ export const useWaitingParticipants = () => {
|
||||
allowEntry: boolean
|
||||
) => {
|
||||
await enterRoom({
|
||||
roomId,
|
||||
roomId: roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
})
|
||||
@@ -78,7 +78,7 @@ export const useWaitingParticipants = () => {
|
||||
await Promise.all(
|
||||
waitingParticipants.map((participant) =>
|
||||
enterRoom({
|
||||
roomId,
|
||||
roomId: roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
})
|
||||
|
||||
@@ -2,8 +2,6 @@ import { Div, Field, H, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Separator as RACSeparator } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRecordingStatuses } from '@/features/recording'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
@@ -40,25 +38,6 @@ export const Admin = () => {
|
||||
isScreenShareEnabled,
|
||||
} = usePublishSourcesManager()
|
||||
|
||||
// Reasons we block the admin from re-enabling encryption mid-call:
|
||||
// - a recording is in progress (the recording server needs plaintext)
|
||||
// - a transcript is being captured (same reason)
|
||||
// SIP participants aren't a blocker — re-enabling encryption while
|
||||
// they're present moves them back into placeholder mode, and the admin
|
||||
// will see the snackbar.
|
||||
//
|
||||
// We use `isStarted` from useRecordingStatuses (metadata-driven) rather
|
||||
// than LK's `useIsRecording`. The metadata transitions through Saving
|
||||
// immediately when the user clicks stop, so the alert clears right
|
||||
// away rather than after the 1–2s LK round-trip.
|
||||
const screenRec = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
const transcript = useRecordingStatuses(RecordingMode.Transcript)
|
||||
const resumeBlockedReason = screenRec.isStarted
|
||||
? t('encryption.blocked.recording')
|
||||
: transcript.isStarted
|
||||
? t('encryption.blocked.transcript')
|
||||
: null
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
@@ -153,122 +132,6 @@ export const Admin = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{readOnlyData?.is_encrypted && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '1rem',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<RACSeparator
|
||||
className={css({
|
||||
border: 'none',
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
background: 'greyscale.250',
|
||||
})}
|
||||
/>
|
||||
<H
|
||||
lvl={2}
|
||||
className={css({
|
||||
fontWeight: 500,
|
||||
})}
|
||||
margin="sm"
|
||||
>
|
||||
{t('encryption.title')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="balance"
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
})}
|
||||
margin={'md'}
|
||||
>
|
||||
{t('encryption.description')}
|
||||
</Text>
|
||||
{(() => {
|
||||
const resumeBlocked =
|
||||
!!readOnlyData?.encryption_paused && resumeBlockedReason != null
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
opacity: resumeBlocked ? 0.45 : 1,
|
||||
pointerEvents: resumeBlocked ? 'none' : undefined,
|
||||
transition: 'opacity 200ms ease',
|
||||
})}
|
||||
aria-disabled={resumeBlocked || undefined}
|
||||
>
|
||||
<Field
|
||||
type="switch"
|
||||
label={t('encryption.toggle.label')}
|
||||
description={
|
||||
readOnlyData?.encryption_paused
|
||||
? t('encryption.toggle.descriptionPaused')
|
||||
: t('encryption.toggle.descriptionLive')
|
||||
}
|
||||
isSelected={!!readOnlyData?.encryption_paused}
|
||||
isDisabled={resumeBlocked}
|
||||
onChange={(paused) =>
|
||||
patchRoom({
|
||||
roomId,
|
||||
room: { encryption_paused: paused },
|
||||
})
|
||||
.then((room) => {
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
})
|
||||
.catch((e) => console.error(e))
|
||||
}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{resumeBlocked && (
|
||||
<div
|
||||
role="alert"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
marginTop: '0.75rem',
|
||||
padding: '0.7rem 0.85rem',
|
||||
borderRadius: '0.5rem',
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
color: '#7c2d12',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
lineHeight: '1.2',
|
||||
})}
|
||||
>
|
||||
⚠
|
||||
</span>
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
color: '#7c2d12',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
})}
|
||||
>
|
||||
{resumeBlockedReason}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
|
||||
@@ -14,10 +14,7 @@ export const Info = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||
|
||||
const data = useRoomData()
|
||||
const baseRoomUrl = getRouteUrl('room', data?.slug)
|
||||
const roomUrl = window.location.hash
|
||||
? `${baseRoomUrl}${window.location.hash}`
|
||||
: baseRoomUrl
|
||||
const roomUrl = getRouteUrl('room', data?.slug)
|
||||
|
||||
const telephony = useTelephony()
|
||||
|
||||
@@ -53,14 +50,7 @@ export const Info = () => {
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
variant="xsNote"
|
||||
className={css({
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'normal',
|
||||
})}
|
||||
>
|
||||
<Text as="p" variant="xsNote" wrap="pretty">
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && (
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ParticipantTileProps,
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
useFeatureContext,
|
||||
useIsEncrypted,
|
||||
useMaybeLayoutContext,
|
||||
useMaybeTrackRefContext,
|
||||
useParticipantTile,
|
||||
@@ -22,12 +24,6 @@ import { Track } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import {
|
||||
IdentityBadge,
|
||||
SipBlockedTileOverlay,
|
||||
DecryptionFailedTileOverlay,
|
||||
} from '@/features/encryption'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
@@ -35,8 +31,6 @@ import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
@@ -82,6 +76,7 @@ export const ParticipantTile: (
|
||||
onParticipantClick,
|
||||
trackRef: trackReference,
|
||||
})
|
||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
|
||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||
@@ -111,19 +106,13 @@ export const ParticipantTile: (
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||
// Show the badge in any encryption-capable room — including while the
|
||||
// room is paused. The badge reflects the *room's nature* (encrypted vs
|
||||
// unencrypted-by-design), which doesn't change when an admin temporarily
|
||||
// pauses encryption for SIP/recording/transcription.
|
||||
const tileRoomData = useRoomData()
|
||||
const showIdentityBadge =
|
||||
!isScreenShare && !!tileRoomData?.is_encrypted
|
||||
|
||||
const participantName = getParticipantName(trackReference.participant)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const interactiveProps = {
|
||||
...elementProps,
|
||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
||||
tabIndex: 0,
|
||||
'aria-label': t('containerLabel', { name: participantName }),
|
||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
@@ -224,18 +213,15 @@ export const ParticipantTile: (
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
{showIdentityBadge && (
|
||||
<IdentityBadge
|
||||
participant={trackReference.participant}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
@@ -249,25 +235,9 @@ export const ParticipantTile: (
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
/>
|
||||
)}
|
||||
{!isScreenShare && (
|
||||
<>
|
||||
<SipBlockedTileOverlay
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
<DecryptionFailedTileOverlay
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
<KeyboardShortcutHint>
|
||||
{t('toolbarHint', {
|
||||
shortcut: formatShortcutLabel(
|
||||
getShortcutDescriptorById('open-shortcuts')?.shortcut
|
||||
),
|
||||
})}
|
||||
</KeyboardShortcutHint>
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -149,12 +149,11 @@ export const SidePanel = () => {
|
||||
activeSubPanelId,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={title}
|
||||
ariaLabel={t('ariaLabel', { title })}
|
||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||
ariaLabel={t('ariaLabel')}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
|
||||
@@ -2,7 +2,7 @@ import { A, Div, Icon, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { ReactNode } from 'react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import {
|
||||
@@ -12,19 +12,12 @@ import {
|
||||
ScreenRecordingSidePanel,
|
||||
} from '@/features/recording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import {
|
||||
EncryptionPhase,
|
||||
PauseEncryptionConfirmDialog,
|
||||
useEncryptionStatus,
|
||||
} from '@/features/encryption'
|
||||
|
||||
|
||||
export interface ToolsButtonProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onPress: () => void
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
const ToolButton = ({
|
||||
@@ -32,11 +25,9 @@ const ToolButton = ({
|
||||
title,
|
||||
description,
|
||||
onPress,
|
||||
isDisabled,
|
||||
}: ToolsButtonProps) => {
|
||||
return (
|
||||
<RACButton
|
||||
isDisabled={isDisabled}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
@@ -48,14 +39,10 @@ const ToolButton = ({
|
||||
width: 'full',
|
||||
backgroundColor: 'gray.50',
|
||||
textAlign: 'start',
|
||||
'&[data-hovered]:not([data-disabled])': {
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primary.50',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'&[data-disabled]': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
onPress={onPress}
|
||||
>
|
||||
@@ -78,7 +65,7 @@ const ToolButton = ({
|
||||
<div>
|
||||
<Text
|
||||
margin={false}
|
||||
as="h2"
|
||||
as="h3"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: 0.25,
|
||||
@@ -111,17 +98,17 @@ export const Tools = () => {
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
const { phase, pauseEncryption } = useEncryptionStatus()
|
||||
const [confirmReason, setConfirmReason] = useState<
|
||||
'recording' | 'transcript' | null
|
||||
>(null)
|
||||
|
||||
// Restore focus to the element that opened the Tools panel
|
||||
// following the same pattern as Chat.
|
||||
useRestoreFocus(isToolsOpen, {
|
||||
// If the active element is a MenuItem (DIV) that will be unmounted when the menu closes,
|
||||
// find the "more options" button ("Plus d'options") that opened the menu
|
||||
resolveTrigger: (activeEl) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||
}
|
||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
||||
return activeEl
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
@@ -145,15 +132,6 @@ export const Tools = () => {
|
||||
break
|
||||
}
|
||||
|
||||
const handlePress = (reason: 'recording' | 'transcript') => {
|
||||
if (phase === EncryptionPhase.ENCRYPTED) {
|
||||
setConfirmReason(reason)
|
||||
return
|
||||
}
|
||||
if (reason === 'recording') openScreenRecording()
|
||||
else openTranscript()
|
||||
}
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
@@ -193,7 +171,7 @@ export const Tools = () => {
|
||||
icon={<Icon type="symbols" name="speech_to_text" />}
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={() => handlePress('transcript')}
|
||||
onPress={() => openTranscript()}
|
||||
/>
|
||||
)}
|
||||
{isScreenRecordingEnabled && (
|
||||
@@ -201,22 +179,9 @@ export const Tools = () => {
|
||||
icon={<Icon type="symbols" name="mode_standby" />}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={() => handlePress('recording')}
|
||||
onPress={() => openScreenRecording()}
|
||||
/>
|
||||
)}
|
||||
<PauseEncryptionConfirmDialog
|
||||
isOpen={confirmReason !== null}
|
||||
onOpenChange={(open) => !open && setConfirmReason(null)}
|
||||
reason={confirmReason ?? 'recording'}
|
||||
onConfirm={async () => {
|
||||
if (!confirmReason) return
|
||||
const ok = await pauseEncryption(confirmReason)
|
||||
if (ok) {
|
||||
if (confirmReason === 'recording') openScreenRecording()
|
||||
else openTranscript()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
+27
-16
@@ -11,7 +11,11 @@ import {
|
||||
TIMEOUT_TICK,
|
||||
timerWorkerScript,
|
||||
} from './TimerWorker'
|
||||
import { BackgroundProcessorInterface, ProcessorConfig, ProcessorType } from '.'
|
||||
import {
|
||||
BackgroundProcessorInterface,
|
||||
BackgroundOptions,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
const PROCESSING_HEIGHT = 144
|
||||
@@ -29,7 +33,7 @@ const DEFAULT_BLUR = '10'
|
||||
* MediaStreamTrackProcessor.
|
||||
*/
|
||||
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
options: ProcessorConfig
|
||||
options: BackgroundOptions
|
||||
name: string
|
||||
processedTrack?: MediaStreamTrack | undefined
|
||||
|
||||
@@ -60,10 +64,15 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
type: ProcessorType
|
||||
virtualBackgroundImage?: HTMLImageElement
|
||||
|
||||
constructor(opts: ProcessorConfig) {
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.name = 'blur'
|
||||
this.options = opts
|
||||
this.type = opts.type
|
||||
|
||||
if (this.options.blurRadius) {
|
||||
this.type = ProcessorType.BLUR
|
||||
} else {
|
||||
this.type = ProcessorType.VIRTUAL
|
||||
}
|
||||
}
|
||||
|
||||
static get isSupported() {
|
||||
@@ -98,12 +107,6 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
}
|
||||
|
||||
_initVirtualBackgroundImage() {
|
||||
if (this.options.type !== 'virtual') {
|
||||
throw new Error(
|
||||
'Virtual background is only supported for virtual background'
|
||||
)
|
||||
}
|
||||
|
||||
const needsUpdate =
|
||||
this.options.imagePath &&
|
||||
this.virtualBackgroundImage &&
|
||||
@@ -115,7 +118,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
}
|
||||
}
|
||||
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
async update(opts: BackgroundOptions): Promise<void> {
|
||||
this.options = opts
|
||||
this._initVirtualBackgroundImage()
|
||||
}
|
||||
@@ -184,7 +187,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_HEIGHT
|
||||
PROCESSING_WIDTH
|
||||
)
|
||||
}
|
||||
|
||||
@@ -209,9 +212,6 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
* TODO: future improvement with WebGL.
|
||||
*/
|
||||
async blur() {
|
||||
if (this.options.type !== 'blur') {
|
||||
throw new Error('Blurring is only supported for blur background')
|
||||
}
|
||||
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
|
||||
for (let i = 0; i < mask.length; ++i) {
|
||||
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
|
||||
@@ -293,7 +293,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
await this.sizeSource()
|
||||
await this.segment()
|
||||
|
||||
if (this.options.type === 'blur') {
|
||||
if (this.options.blurRadius) {
|
||||
await this.blur()
|
||||
} else {
|
||||
await this.drawVirtualBackground()
|
||||
@@ -354,4 +354,15 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.timerWorker?.terminate()
|
||||
this.imageSegmenter?.close()
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new BackgroundCustomProcessor(this.options)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: this.type,
|
||||
options: this.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,4 +332,15 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
||||
this.timerWorker?.terminate()
|
||||
this.faceLandmarker?.close()
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new FaceLandmarksProcessor(this.options)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: this.type,
|
||||
options: this.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-15
@@ -1,23 +1,28 @@
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import {
|
||||
BackgroundBlur,
|
||||
BackgroundTransformer,
|
||||
ProcessorWrapper,
|
||||
VirtualBackground,
|
||||
} from '@livekit/track-processors'
|
||||
import { ProcessorConfig, BackgroundProcessorInterface, ProcessorType } from '.'
|
||||
import {
|
||||
BackgroundOptions,
|
||||
BackgroundProcessorInterface,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
|
||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||
processor: ProcessorWrapper<{ imagePath?: string; blurRadius?: number }>
|
||||
opts: ProcessorConfig
|
||||
processor: ProcessorWrapper<BackgroundOptions>
|
||||
opts: BackgroundOptions
|
||||
processorType: ProcessorType
|
||||
|
||||
constructor(opts: ProcessorConfig) {
|
||||
constructor(opts: BackgroundOptions) {
|
||||
this.opts = opts
|
||||
|
||||
if (opts.type === 'virtual') {
|
||||
if (opts.imagePath) {
|
||||
this.processorType = ProcessorType.VIRTUAL
|
||||
this.processor = VirtualBackground(opts.imagePath)
|
||||
} else if (opts.type === 'blur') {
|
||||
} else if (opts.blurRadius !== undefined) {
|
||||
this.processorType = ProcessorType.BLUR
|
||||
this.processor = BackgroundBlur(opts.blurRadius)
|
||||
} else {
|
||||
@@ -39,23 +44,24 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
return this.processor.destroy()
|
||||
}
|
||||
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
this.opts = opts
|
||||
|
||||
const newProcessorType =
|
||||
opts.type === 'virtual' ? ProcessorType.VIRTUAL : ProcessorType.BLUR
|
||||
async update(opts: BackgroundOptions): Promise<void> {
|
||||
const newProcessorType = opts.imagePath
|
||||
? ProcessorType.VIRTUAL
|
||||
: ProcessorType.BLUR
|
||||
|
||||
let processedOpts = opts
|
||||
if (newProcessorType !== this.processorType) {
|
||||
this.processorType = newProcessorType
|
||||
if (newProcessorType === ProcessorType.VIRTUAL) {
|
||||
this.processor.name = 'virtual-background'
|
||||
processedOpts = { ...opts, blurRadius: undefined }
|
||||
} else {
|
||||
this.processor.name = 'background-blur'
|
||||
processedOpts = { ...opts, imagePath: undefined }
|
||||
}
|
||||
}
|
||||
await this.processor.updateTransformerOptions(
|
||||
opts as { imagePath?: string; blurRadius?: number }
|
||||
)
|
||||
await this.processor.updateTransformerOptions(processedOpts)
|
||||
this.opts = processedOpts
|
||||
}
|
||||
|
||||
get name() {
|
||||
@@ -67,6 +73,17 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.opts
|
||||
return (this.processor.transformer as BackgroundTransformer).options
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new UnifiedBackgroundTrackProcessor(this.options || this.opts)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: this.processorType,
|
||||
options: this.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,23 @@ import { ProcessorWrapper } from '@livekit/track-processors'
|
||||
import { Track, TrackProcessor } from 'livekit-client'
|
||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
||||
|
||||
export type BackgroundOptions = {
|
||||
blurRadius?: number
|
||||
imagePath?: string
|
||||
}
|
||||
|
||||
export interface ProcessorSerialized {
|
||||
type: ProcessorType
|
||||
options: BackgroundOptions
|
||||
}
|
||||
|
||||
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
||||
update(opts: BackgroundOptions): Promise<void>
|
||||
options: BackgroundOptions
|
||||
clone(): BackgroundProcessorInterface
|
||||
serialize(): ProcessorSerialized
|
||||
}
|
||||
|
||||
export enum ProcessorType {
|
||||
BLUR = 'blur',
|
||||
@@ -10,16 +26,6 @@ export enum ProcessorType {
|
||||
FACE_LANDMARKS = 'faceLandmarks',
|
||||
}
|
||||
|
||||
export type ProcessorConfig =
|
||||
| { type: ProcessorType.BLUR; blurRadius: number }
|
||||
| { type: ProcessorType.VIRTUAL; imagePath: string; fileId?: string }
|
||||
| ({ type: ProcessorType.FACE_LANDMARKS } & FaceLandmarksOptions)
|
||||
|
||||
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
||||
update(opts: ProcessorConfig): Promise<void>
|
||||
options: ProcessorConfig
|
||||
}
|
||||
|
||||
export class BackgroundProcessorFactory {
|
||||
static hasModernApiSupport() {
|
||||
return ProcessorWrapper.hasModernApiSupport
|
||||
@@ -30,27 +36,28 @@ export class BackgroundProcessorFactory {
|
||||
}
|
||||
|
||||
static getProcessor(
|
||||
config: ProcessorConfig
|
||||
type: ProcessorType,
|
||||
opts: BackgroundOptions
|
||||
): BackgroundProcessorInterface | undefined {
|
||||
const isBlur = config.type === ProcessorType.BLUR
|
||||
const isVirtual = config.type === ProcessorType.VIRTUAL
|
||||
const isBlur = type === ProcessorType.BLUR
|
||||
const isVirtual = type === ProcessorType.VIRTUAL
|
||||
|
||||
if (!isBlur && !isVirtual) return undefined
|
||||
|
||||
if (ProcessorWrapper.isSupported) {
|
||||
return new UnifiedBackgroundTrackProcessor(config)
|
||||
return new UnifiedBackgroundTrackProcessor(opts)
|
||||
}
|
||||
|
||||
if (BackgroundCustomProcessor.isSupported) {
|
||||
return new BackgroundCustomProcessor(config)
|
||||
return new BackgroundCustomProcessor(opts)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
static fromProcessorConfig(data?: ProcessorConfig) {
|
||||
if (data) {
|
||||
return BackgroundProcessorFactory.getProcessor(data)
|
||||
static deserializeProcessor(data?: ProcessorSerialized) {
|
||||
if (data?.type) {
|
||||
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -69,10 +69,10 @@ const SelectDevicePermissions = <T extends string | number>({
|
||||
iconComponent={iconComponent}
|
||||
placeholder={items.length === 0 ? t('loading') : t('select')}
|
||||
selectedKey={selectedKey}
|
||||
onSelectionChange={async (key) => {
|
||||
onSelectionChange={(key) => {
|
||||
if (key === selectedKey) return
|
||||
await setActiveMediaDevice(key as string)
|
||||
onSubmit?.(key as string)
|
||||
setActiveMediaDevice(key as string)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -82,8 +82,8 @@ export const VideoDeviceControl = ({
|
||||
*
|
||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||
*/
|
||||
const processor = BackgroundProcessorFactory.fromProcessorConfig(
|
||||
userChoices.processorConfig
|
||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
||||
userChoices.processorSerialized
|
||||
)
|
||||
|
||||
const toggle = trackProps.toggle as (
|
||||
|
||||
+9
-35
@@ -1,23 +1,15 @@
|
||||
import { RiRecordCircleLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import {
|
||||
EncryptionPhase,
|
||||
PauseEncryptionConfirmDialog,
|
||||
useEncryptionStatus,
|
||||
} from '@/features/encryption'
|
||||
|
||||
export const ScreenRecordingMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
|
||||
useSidePanel()
|
||||
const { phase, pauseEncryption } = useEncryptionStatus()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
@@ -26,33 +18,15 @@ export const ScreenRecordingMenuItem = () => {
|
||||
|
||||
if (!hasScreenRecordingAccess) return null
|
||||
|
||||
const handlePress = () => {
|
||||
if (phase === EncryptionPhase.ENCRYPTED) {
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
if (!isScreenRecordingOpen) openScreenRecording()
|
||||
else toggleTools()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={handlePress}
|
||||
>
|
||||
<RiRecordCircleLine size={20} />
|
||||
{t('screenRecording')}
|
||||
</MenuItem>
|
||||
<PauseEncryptionConfirmDialog
|
||||
isOpen={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
reason="recording"
|
||||
onConfirm={async () => {
|
||||
const ok = await pauseEncryption('recording')
|
||||
if (ok) openScreenRecording()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() =>
|
||||
!isScreenRecordingOpen ? openScreenRecording() : toggleTools()
|
||||
}
|
||||
>
|
||||
<RiRecordCircleLine size={20} />
|
||||
{t('screenRecording')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
+7
-35
@@ -1,22 +1,14 @@
|
||||
import { RiFileTextLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import {
|
||||
EncryptionPhase,
|
||||
PauseEncryptionConfirmDialog,
|
||||
useEncryptionStatus,
|
||||
} from '@/features/encryption'
|
||||
|
||||
export const TranscriptMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
|
||||
const { phase, pauseEncryption } = useEncryptionStatus()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
@@ -25,33 +17,13 @@ export const TranscriptMenuItem = () => {
|
||||
|
||||
if (!hasTranscriptAccess) return null
|
||||
|
||||
const handlePress = () => {
|
||||
if (phase === EncryptionPhase.ENCRYPTED) {
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
if (!isTranscriptOpen) openTranscript()
|
||||
else toggleTools()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={handlePress}
|
||||
>
|
||||
<RiFileTextLine size={20} />
|
||||
{t('transcript')}
|
||||
</MenuItem>
|
||||
<PauseEncryptionConfirmDialog
|
||||
isOpen={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
reason="transcript"
|
||||
onConfirm={async () => {
|
||||
const ok = await pauseEncryption('transcript')
|
||||
if (ok) openTranscript()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<MenuItem
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
onAction={() => (!isTranscriptOpen ? openTranscript() : toggleTools())}
|
||||
>
|
||||
<RiFileTextLine size={20} />
|
||||
{t('transcript')}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
+23
-12
@@ -21,7 +21,6 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
||||
import { PinBadge } from './PinBadge'
|
||||
import { IdentityBadge, useEncryptionStatus, EncryptionPhase } from '@/features/encryption'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
participant: Participant
|
||||
@@ -98,8 +97,6 @@ export const ParticipantListItem = ({
|
||||
participant,
|
||||
}: ParticipantListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { phase } = useEncryptionStatus()
|
||||
const showIdentityBadge = phase !== EncryptionPhase.UNENCRYPTED
|
||||
const name = participant.name || participant.identity
|
||||
return (
|
||||
<HStack
|
||||
@@ -125,21 +122,35 @@ export const ParticipantListItem = ({
|
||||
variant="sm"
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '150px',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
{isLocal(participant) && ` (${t('participants.you')})`}
|
||||
<span
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '120px',
|
||||
display: 'block',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{isLocal(participant) && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
({t('participants.you')})
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
{getParticipantIsRoomAdmin(participant) && (
|
||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||
)}
|
||||
{showIdentityBadge && (
|
||||
<IdentityBadge participant={participant} size="sm" />
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack>
|
||||
|
||||
+19
-17
@@ -1,12 +1,10 @@
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { IdentityBadge } from '@/features/encryption'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
|
||||
export const WaitingParticipantListItem = ({
|
||||
participant,
|
||||
@@ -16,8 +14,6 @@ export const WaitingParticipantListItem = ({
|
||||
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const roomData = useRoomData()
|
||||
const showIdentityBadge = !!roomData?.is_encrypted
|
||||
|
||||
return (
|
||||
<HStack
|
||||
@@ -34,31 +30,37 @@ export const WaitingParticipantListItem = ({
|
||||
className={css({
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
gap: '0.35rem',
|
||||
})}
|
||||
>
|
||||
<Avatar name={participant.username} bgColor={participant.color} />
|
||||
<VStack gap={0} alignItems="start" className={css({ flex: 1, minWidth: 0 })}>
|
||||
<Text
|
||||
variant="sm"
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0.1rem 0.25rem',
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</Text>
|
||||
{showIdentityBadge && (
|
||||
<IdentityBadge isAuthenticated={participant.is_authenticated} />
|
||||
)}
|
||||
</VStack>
|
||||
</span>
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
gap="0.25rem"
|
||||
className={css({ flexShrink: '0' })}
|
||||
className={css({
|
||||
flexShrink: '0',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { LocalVideoTrack } from 'livekit-client'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { EffectsConfiguration } from './EffectsConfiguration'
|
||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
||||
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
|
||||
import { TrackSource } from '@livekit/protocol'
|
||||
|
||||
export const Effects = () => {
|
||||
const { cameraTrack } = useLocalParticipant()
|
||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
||||
const { saveProcessorSerialized } = usePersistentUserChoices()
|
||||
|
||||
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
||||
|
||||
@@ -22,6 +24,9 @@ export const Effects = () => {
|
||||
isDisabled={!canPublishCamera}
|
||||
videoTrack={localCameraTrack}
|
||||
layout="vertical"
|
||||
onSubmit={(processor) =>
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+240
-694
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user