mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 04:39:16 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8812e12849 | |||
| 80c9620368 | |||
| 8bdca048f4 | |||
| 3698ac09eb |
@@ -223,6 +223,8 @@ jobs:
|
|||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
REDIS_URL: redis://localhost:6379/1
|
REDIS_URL: redis://localhost:6379/1
|
||||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
|
LIVEKIT_API_SECRET: secret
|
||||||
|
LIVEKIT_API_KEY: devkey
|
||||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
@@ -304,8 +306,7 @@ jobs:
|
|||||||
working-directory: src/summary
|
working-directory: src/summary
|
||||||
|
|
||||||
env:
|
env:
|
||||||
V1_TENANT_ID: 'test-tenant'
|
APP_API_TOKEN: "test-api-token"
|
||||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
|
||||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||||
AWS_S3_ACCESS_KEY_ID: "meet"
|
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||||
@@ -317,6 +318,8 @@ jobs:
|
|||||||
LLM_BASE_URL: "https://configure-your-url.com"
|
LLM_BASE_URL: "https://configure-your-url.com"
|
||||||
LLM_API_KEY: "test-llm-secret"
|
LLM_API_KEY: "test-llm-secret"
|
||||||
LLM_MODEL: "test-llm-model"
|
LLM_MODEL: "test-llm-model"
|
||||||
|
WEBHOOK_API_TOKEN: "test-webhook-secret"
|
||||||
|
WEBHOOK_URL: "https://configure-your-url.com"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -8,20 +8,9 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- 🔒️(helm) Add pod and container securityContext #1197
|
|
||||||
- ✨(summary) add routes v2 for async STT and summary tasks #1171
|
|
||||||
- ✅(backend) add unit tests for JwtTokenService #1232
|
|
||||||
|
|
||||||
## [1.13.0] - 2026-03-31
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ⬆️(dependencies) update python dependencies
|
- ⬆️(dependencies) update python dependencies
|
||||||
- ♿️(frontend) add explicit region for call controls #1216
|
|
||||||
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
|
|
||||||
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ create-env-files: \
|
|||||||
env.d/development/crowdin \
|
env.d/development/crowdin \
|
||||||
env.d/development/postgresql \
|
env.d/development/postgresql \
|
||||||
env.d/development/kc_postgresql \
|
env.d/development/kc_postgresql \
|
||||||
env.d/development/summary \
|
env.d/development/summary
|
||||||
env.d/development/kube-secret
|
|
||||||
.PHONY: create-env-files
|
.PHONY: create-env-files
|
||||||
|
|
||||||
bootstrap: ## Prepare Docker images for the project
|
bootstrap: ## Prepare Docker images for the project
|
||||||
@@ -126,10 +125,16 @@ run-summary: ## start only the summary application and all needed services
|
|||||||
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
|
||||||
.PHONY: run-summary
|
.PHONY: run-summary
|
||||||
|
|
||||||
|
run-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:
|
||||||
run: ## start the wsgi (production) and development server
|
run: ## start the wsgi (production) and development server
|
||||||
@$(MAKE) run-backend
|
@$(MAKE) run-backend
|
||||||
@$(MAKE) run-summary
|
@$(MAKE) run-summary
|
||||||
|
@$(MAKE) run-sip
|
||||||
@$(COMPOSE) up --force-recreate -d frontend
|
@$(COMPOSE) up --force-recreate -d frontend
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
|
|
||||||
@@ -266,9 +271,6 @@ env.d/development/kc_postgresql:
|
|||||||
env.d/development/summary:
|
env.d/development/summary:
|
||||||
cp -n env.d/development/summary.dist env.d/development/summary
|
cp -n env.d/development/summary.dist env.d/development/summary
|
||||||
|
|
||||||
env.d/development/kube-secret:
|
|
||||||
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
|
|
||||||
|
|
||||||
# -- Internationalization
|
# -- Internationalization
|
||||||
|
|
||||||
env.d/development/crowdin:
|
env.d/development/crowdin:
|
||||||
@@ -363,6 +365,10 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
|||||||
./bin/install-external-secrets.sh
|
./bin/install-external-secrets.sh
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
|
start-tilt: ## start the kubernetes cluster using kind
|
||||||
|
tilt up --namespace=meet -f ./bin/Tiltfile
|
||||||
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
|
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/suitenumerique/meet/stargazers/">
|
<a href="https://github.com/suitenumerique/meet/stargazers/">
|
||||||
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
|
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
|
||||||
@@ -12,11 +11,11 @@
|
|||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
||||||
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
||||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -28,25 +27,65 @@
|
|||||||
## La Suite Meet: Simple Video Conferencing
|
## La Suite Meet: Simple Video Conferencing
|
||||||
|
|
||||||
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Optimized for stability in large meetings (+100 p.)
|
- Optimized for stability in large meetings (+100 p.)
|
||||||
- Support for multiple screen sharing streams
|
- Support for multiple screen sharing streams
|
||||||
- Non-persistent, secure chat
|
- Non-persistent, secure chat
|
||||||
- End-to-end encryption (coming soon)
|
- End-to-end encryption with passphrase-in-link key distribution
|
||||||
- Meeting recording
|
- Meeting recording
|
||||||
- Meeting transcription & Summary (currently in beta)
|
- Meeting transcription & Summary (currently in beta)
|
||||||
- Telephony integration
|
- Telephony integration
|
||||||
- Secure participation with robust authentication and access control
|
- Secure participation with robust authentication and access control
|
||||||
- Customizable frontend style
|
- Customizable frontend style
|
||||||
- LiveKit Advances features including :
|
- LiveKit Advances features including :
|
||||||
- speaker detection
|
- speaker detection
|
||||||
- simulcast
|
- simulcast
|
||||||
- end-to-end optimizations
|
- end-to-end optimizations
|
||||||
- selective subscription
|
- selective subscription
|
||||||
- SVC codecs (VP9, AV1)
|
- SVC codecs (VP9, AV1)
|
||||||
|
|
||||||
|
### End-to-end encryption
|
||||||
|
|
||||||
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 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).
|
||||||
|
|
||||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||||
|
|
||||||
@@ -63,7 +102,6 @@ On the 25th of January 2026, David Amiel, France’s Minister for Civil Service
|
|||||||
- [Philosophy](#philosophy)
|
- [Philosophy](#philosophy)
|
||||||
- [Open source](#open-source)
|
- [Open source](#open-source)
|
||||||
|
|
||||||
|
|
||||||
## Get started
|
## Get started
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
@@ -82,15 +120,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.
|
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
|
||||||
|
|
||||||
#### Known instances
|
#### Known instances
|
||||||
|
|
||||||
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
||||||
|
|
||||||
| Url | Org | Access |
|
| Url | Org | Access |
|
||||||
|---------------------------------------------------------------| --- | ------- |
|
| ------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -100,7 +138,6 @@ 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))
|
- 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)
|
- 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
|
## 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.
|
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.
|
||||||
@@ -109,7 +146,6 @@ 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.
|
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
|
## Open-source
|
||||||
|
|
||||||
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
|
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
|
||||||
@@ -121,14 +157,13 @@ 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).
|
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
|
||||||
|
|
||||||
|
|
||||||
## Contributors 🧞
|
## Contributors 🧞
|
||||||
|
|
||||||
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
|
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
|
||||||
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
|
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
|
||||||
</a>
|
</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!
|
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.
|
This project is tested with BrowserStack.
|
||||||
@@ -137,4 +172,3 @@ This project is tested with BrowserStack.
|
|||||||
|
|
||||||
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
|
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).
|
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
|
||||||
|
|
||||||
|
|||||||
+2
-9
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
|||||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||||
namespace_create('meet')
|
namespace_create('meet')
|
||||||
|
|
||||||
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
DEV_ENV = os.getenv('DEV_ENV', 'dev')
|
||||||
|
|
||||||
if DEV_ENV == 'dev-dinum':
|
if DEV_ENV == 'dev-dinum':
|
||||||
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
||||||
@@ -95,19 +95,12 @@ docker_build(
|
|||||||
)
|
)
|
||||||
clean_old_images('localhost:5001/meet-livekit')
|
clean_old_images('localhost:5001/meet-livekit')
|
||||||
|
|
||||||
load('ext://secret', 'secret_yaml_generic')
|
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||||
k8s_yaml(secret_yaml_generic(
|
|
||||||
name="secret-dev",
|
|
||||||
from_env_file="../env.d/development/kube-secret"
|
|
||||||
))
|
|
||||||
|
|
||||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
|
||||||
|
|
||||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
|
||||||
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
||||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||||
|
|||||||
+55
-2
@@ -168,8 +168,7 @@ services:
|
|||||||
- "3000:8080"
|
- "3000:8080"
|
||||||
|
|
||||||
dockerize:
|
dockerize:
|
||||||
image: jwilder/dockerize
|
image: powerman/dockerize:0.19.0
|
||||||
platform: linux/x86_64
|
|
||||||
|
|
||||||
crowdin:
|
crowdin:
|
||||||
image: crowdin/cli:4.0.0
|
image: crowdin/cli:4.0.0
|
||||||
@@ -246,6 +245,60 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- 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:
|
redis-summary:
|
||||||
image: redis
|
image: redis
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+20
-3
@@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-chromium",
|
"username": "user-e2e-chromium",
|
||||||
"email": "user@chromium.e2e",
|
"email": "user.test@chromium.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Chromium",
|
"lastName": "Chromium",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-webkit",
|
"username": "user-e2e-webkit",
|
||||||
"email": "user@webkit.e2e",
|
"email": "user.test@webkit.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Webkit",
|
"lastName": "Webkit",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "user-e2e-firefox",
|
"username": "user-e2e-firefox",
|
||||||
"email": "user@firefox.e2e",
|
"email": "user.test@firefox.test",
|
||||||
"firstName": "E2E",
|
"firstName": "E2E",
|
||||||
"lastName": "Firefox",
|
"lastName": "Firefox",
|
||||||
"enabled": "true",
|
"enabled": "true",
|
||||||
@@ -845,6 +845,23 @@
|
|||||||
"offline_access",
|
"offline_access",
|
||||||
"microprofile-jwt"
|
"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": [
|
"clientScopes": [
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// 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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// 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 = "*"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
<!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
@@ -1,4 +0,0 @@
|
|||||||
WHISPERX_BASE_URL=https://configure-your-url.com
|
|
||||||
WHISPERX_API_KEY=<key>
|
|
||||||
LLM_BASE_URL=https://configure-your-url.com
|
|
||||||
LLM_API_KEY=<key>
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.13.0"
|
version = "1.12.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.4.5",
|
"livekit-agents==1.4.5",
|
||||||
|
|||||||
@@ -73,5 +73,8 @@ def get_frontend_configuration(request):
|
|||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
frontend_configuration["encryption"] = {
|
||||||
|
"enabled": settings.ENCRYPTION_ENABLED,
|
||||||
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -30,8 +30,17 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
fields = [
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
"id",
|
||||||
|
"sub",
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"short_name",
|
||||||
|
"timezone",
|
||||||
|
"language",
|
||||||
|
"default_encryption",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "sub", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
|
|
||||||
class UserLightSerializer(serializers.ModelSerializer):
|
class UserLightSerializer(serializers.ModelSerializer):
|
||||||
@@ -74,6 +83,7 @@ class ResourceAccessSerializerMixin:
|
|||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
"Only owners of a room can assign other users as owners."
|
"Only owners of a room can assign other users as owners."
|
||||||
)
|
)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def validate_resource(self, resource):
|
def validate_resource(self, resource):
|
||||||
@@ -128,9 +138,45 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Room
|
model = models.Room
|
||||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"slug",
|
||||||
|
"configuration",
|
||||||
|
"access_level",
|
||||||
|
"pin_code",
|
||||||
|
"is_encrypted",
|
||||||
|
"encryption_paused",
|
||||||
|
]
|
||||||
read_only_fields = ["id", "slug", "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):
|
def to_representation(self, instance):
|
||||||
"""
|
"""
|
||||||
Add users only for administrator users.
|
Add users only for administrator users.
|
||||||
@@ -172,6 +218,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
if should_access_room:
|
if should_access_room:
|
||||||
room_id = f"{instance.id!s}"
|
room_id = f"{instance.id!s}"
|
||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
|
|
||||||
output["livekit"] = utils.generate_livekit_config(
|
output["livekit"] = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
@@ -265,7 +312,7 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
|||||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||||
"""Validate request entry data."""
|
"""Validate request entry data."""
|
||||||
|
|
||||||
username = serializers.CharField(required=True)
|
username = serializers.CharField(required=True, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||||
|
|||||||
@@ -281,6 +281,14 @@ class RoomViewSet(
|
|||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set the current user as owner of the newly created room."""
|
"""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()
|
room = serializer.save()
|
||||||
models.ResourceAccess.objects.create(
|
models.ResourceAccess.objects.create(
|
||||||
resource=room,
|
resource=room,
|
||||||
@@ -306,8 +314,16 @@ class RoomViewSet(
|
|||||||
serializer = serializers.StartRecordingSerializer(data=request.data)
|
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||||
|
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
|
print(
|
||||||
|
"[start-recording] body=",
|
||||||
|
request.data,
|
||||||
|
"errors=",
|
||||||
|
serializer.errors,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
|
{"detail": "Invalid request.", "errors": serializer.errors},
|
||||||
|
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
mode = serializer.validated_data["mode"]
|
mode = serializer.validated_data["mode"]
|
||||||
@@ -396,12 +412,14 @@ class RoomViewSet(
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
validated_data = serializer.validated_data
|
||||||
|
|
||||||
lobby_service = LobbyService()
|
lobby_service = LobbyService()
|
||||||
|
|
||||||
participant, livekit = lobby_service.request_entry(
|
participant, livekit = lobby_service.request_entry(
|
||||||
room=room,
|
room=room,
|
||||||
request=request,
|
request=request,
|
||||||
**serializer.validated_data,
|
**validated_data,
|
||||||
)
|
)
|
||||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||||
lobby_service.prepare_response(response, participant.id)
|
lobby_service.prepare_response(response, participant.id)
|
||||||
@@ -464,6 +482,7 @@ class RoomViewSet(
|
|||||||
lobby_service = LobbyService()
|
lobby_service = LobbyService()
|
||||||
|
|
||||||
participants = lobby_service.list_waiting_participants(room.id)
|
participants = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
return drf_response.Response({"participants": participants})
|
return drf_response.Response({"participants": participants})
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 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,6 +200,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
"Unselect this instead of deleting accounts."
|
"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()
|
objects = auth_models.UserManager()
|
||||||
|
|
||||||
@@ -388,6 +396,25 @@ class Room(Resource):
|
|||||||
choices=RoomAccessLevel.choices,
|
choices=RoomAccessLevel.choices,
|
||||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
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(
|
configuration = models.JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=dict,
|
default=dict,
|
||||||
@@ -413,13 +440,44 @@ class Room(Resource):
|
|||||||
return capfirst(self.name)
|
return capfirst(self.name)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
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:
|
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||||
self.pin_code = self.generate_unique_pin_code(
|
self.pin_code = self.generate_unique_pin_code(
|
||||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
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)
|
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):
|
def clean_fields(self, exclude=None):
|
||||||
"""
|
"""
|
||||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ from core.recording.services.recording_events import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
|
from .participants_management import (
|
||||||
|
ParticipantsManagement,
|
||||||
|
ParticipantsManagementException,
|
||||||
|
)
|
||||||
from .telephony import TelephonyException, TelephonyService
|
from .telephony import TelephonyException, TelephonyService
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -185,6 +189,72 @@ class LiveKitEventsService:
|
|||||||
f"Failed to process limit reached event for recording {recording}"
|
f"Failed to process limit reached event for recording {recording}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
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):
|
def _handle_room_started(self, data):
|
||||||
"""Handle 'room_started' event."""
|
"""Handle 'room_started' event."""
|
||||||
|
|
||||||
@@ -202,6 +272,22 @@ class LiveKitEventsService:
|
|||||||
except models.Room.DoesNotExist as err:
|
except models.Room.DoesNotExist as err:
|
||||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from 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:
|
if settings.ROOM_TELEPHONY_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.telephony_service.create_dispatch_rule(room)
|
self.telephony_service.create_dispatch_rule(room)
|
||||||
|
|||||||
@@ -46,14 +46,18 @@ class LobbyParticipant:
|
|||||||
username: str
|
username: str
|
||||||
color: str
|
color: str
|
||||||
id: 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, str]:
|
def to_dict(self) -> Dict[str, object]:
|
||||||
"""Serialize the participant object to a dict representation."""
|
"""Serialize the participant object to a dict representation."""
|
||||||
return {
|
return {
|
||||||
"status": self.status.value,
|
"status": self.status.value,
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
|
"is_authenticated": self.is_authenticated,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -68,6 +72,7 @@ class LobbyParticipant:
|
|||||||
username=data["username"],
|
username=data["username"],
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
color=data["color"],
|
color=data["color"],
|
||||||
|
is_authenticated=bool(data.get("is_authenticated", False)),
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError) as e:
|
except (KeyError, ValueError) as e:
|
||||||
logger.exception("Error creating Participant from dict:")
|
logger.exception("Error creating Participant from dict:")
|
||||||
@@ -99,7 +104,7 @@ class LobbyService:
|
|||||||
key=settings.LOBBY_COOKIE_NAME,
|
key=settings.LOBBY_COOKIE_NAME,
|
||||||
value=participant_id,
|
value=participant_id,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=True,
|
secure=not settings.DEBUG,
|
||||||
samesite="Lax",
|
samesite="Lax",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,6 +157,7 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=utils.generate_color(participant_id),
|
color=utils.generate_color(participant_id),
|
||||||
|
is_authenticated=request.user.is_authenticated,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||||
@@ -170,13 +176,17 @@ class LobbyService:
|
|||||||
livekit_config = None
|
livekit_config = None
|
||||||
|
|
||||||
if participant is None:
|
if participant is None:
|
||||||
participant = self.enter(room.id, participant_id, username)
|
participant = self.enter(
|
||||||
|
room.id,
|
||||||
|
participant_id,
|
||||||
|
username,
|
||||||
|
is_authenticated=request.user.is_authenticated,
|
||||||
|
)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||||
self.refresh_waiting_status(room.id, participant_id)
|
self.refresh_waiting_status(room.id, participant_id)
|
||||||
|
|
||||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||||
# wrongly named, contains access token to join a room
|
|
||||||
livekit_config = utils.generate_livekit_config(
|
livekit_config = utils.generate_livekit_config(
|
||||||
room_id=room_id,
|
room_id=room_id,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
@@ -201,7 +211,11 @@ class LobbyService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def enter(
|
def enter(
|
||||||
self, room_id: UUID, participant_id: str, username: str
|
self,
|
||||||
|
room_id: UUID,
|
||||||
|
participant_id: str,
|
||||||
|
username: str,
|
||||||
|
is_authenticated: bool = False,
|
||||||
) -> LobbyParticipant:
|
) -> LobbyParticipant:
|
||||||
"""Add participant to waiting lobby.
|
"""Add participant to waiting lobby.
|
||||||
|
|
||||||
@@ -216,6 +230,7 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=color,
|
color=color,
|
||||||
|
is_authenticated=is_authenticated,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
|||||||
"""Test that tokens signed with incorrect signature are rejected."""
|
"""Test that tokens signed with incorrect signature are rejected."""
|
||||||
|
|
||||||
settings.ROOM_SUBTITLE_ENABLED = True
|
settings.ROOM_SUBTITLE_ENABLED = True
|
||||||
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret-padded-to-32-bytes!!"
|
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def mock_livekit_config(settings):
|
|||||||
"""Mock LiveKit configuration."""
|
"""Mock LiveKit configuration."""
|
||||||
settings.LIVEKIT_CONFIGURATION = {
|
settings.LIVEKIT_CONFIGURATION = {
|
||||||
"api_key": "test_api_key",
|
"api_key": "test_api_key",
|
||||||
"api_secret": "test_api_secret_padded_to_32bytes!",
|
"api_secret": "test_api_secret",
|
||||||
"url": "https://test-livekit.example.com/",
|
"url": "https://test-livekit.example.com/",
|
||||||
}
|
}
|
||||||
return settings.LIVEKIT_CONFIGURATION
|
return settings.LIVEKIT_CONFIGURATION
|
||||||
|
|||||||
@@ -1,541 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for JWT token service.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=W0212,W0621
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
|
|
||||||
import jwt as pyjwt
|
|
||||||
import pytest
|
|
||||||
from freezegun import freeze_time
|
|
||||||
|
|
||||||
from core.services.jwt_token import (
|
|
||||||
JwtTokenService,
|
|
||||||
TokenDecodeError,
|
|
||||||
TokenExpiredError,
|
|
||||||
TokenInvalidError,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Fixtures --
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def jwt_service():
|
|
||||||
"""Create a JWT token service for testing."""
|
|
||||||
return JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="test-issuer",
|
|
||||||
audience="test-audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_user():
|
|
||||||
"""Create a mock user with a string ID."""
|
|
||||||
user = mock.Mock()
|
|
||||||
user.id = "test-user-id"
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
# -- __init__ / Configuration --
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_missing_secret_key():
|
|
||||||
"""Missing secret key should raise ImproperlyConfigured."""
|
|
||||||
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
|
|
||||||
JwtTokenService(
|
|
||||||
secret_key="",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_none_secret_key():
|
|
||||||
"""None secret key should raise ImproperlyConfigured."""
|
|
||||||
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
|
|
||||||
JwtTokenService(
|
|
||||||
secret_key=None,
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_missing_algorithm():
|
|
||||||
"""Missing algorithm should raise ImproperlyConfigured."""
|
|
||||||
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
|
|
||||||
JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_none_algorithm():
|
|
||||||
"""None algorithm should raise ImproperlyConfigured."""
|
|
||||||
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
|
|
||||||
JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm=None,
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_missing_token_type():
|
|
||||||
"""Missing token type should raise ImproperlyConfigured."""
|
|
||||||
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
|
|
||||||
JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_none_token_type():
|
|
||||||
"""None token type should raise ImproperlyConfigured."""
|
|
||||||
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
|
|
||||||
JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_none_expiration_seconds():
|
|
||||||
"""None expiration seconds should raise ImproperlyConfigured."""
|
|
||||||
with pytest.raises(ImproperlyConfigured, match="Expiration's seconds is required"):
|
|
||||||
JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=None,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_zero_expiration_seconds_is_accepted():
|
|
||||||
"""expiration_seconds=0 is falsy but should be accepted — token expires immediately."""
|
|
||||||
service = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=0,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
assert service._expiration_seconds == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_stores_config_correctly():
|
|
||||||
"""All config values should be stored correctly on the instance."""
|
|
||||||
service = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="my-issuer",
|
|
||||||
audience="my-audience",
|
|
||||||
expiration_seconds=1800,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
assert service._key == "test-secret-padded-to-32-bytes!!"
|
|
||||||
assert service._algorithm == "HS256"
|
|
||||||
assert service._issuer == "my-issuer"
|
|
||||||
assert service._audience == "my-audience"
|
|
||||||
assert service._expiration_seconds == 1800
|
|
||||||
assert service._token_type == "Bearer"
|
|
||||||
|
|
||||||
|
|
||||||
# -- generate_jwt / Return shape --
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_always_returns_required_keys(jwt_service, mock_user):
|
|
||||||
"""Response always contains access_token, token_type, and expires_in."""
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
|
||||||
|
|
||||||
assert "access_token" in result
|
|
||||||
assert "token_type" in result
|
|
||||||
assert "expires_in" in result
|
|
||||||
assert result["token_type"] == "Bearer"
|
|
||||||
assert result["expires_in"] == 3600
|
|
||||||
assert isinstance(result["access_token"], str)
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_scope_present_when_provided(jwt_service, mock_user):
|
|
||||||
"""scope key should be present in response when scope is provided."""
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="read write")
|
|
||||||
|
|
||||||
assert result["scope"] == "read write"
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_scope_absent_when_empty(jwt_service, mock_user):
|
|
||||||
"""scope key should be absent from response when scope is empty."""
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="")
|
|
||||||
|
|
||||||
assert "scope" not in result
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_scope_absent_when_none(jwt_service, mock_user):
|
|
||||||
"""scope key should be absent from response when scope is None."""
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope=None)
|
|
||||||
|
|
||||||
assert "scope" not in result
|
|
||||||
|
|
||||||
|
|
||||||
# -- generate_jwt / Payload correctness --
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_payload_contains_required_claims(jwt_service, mock_user):
|
|
||||||
"""Payload should always contain iat, exp, and user_id."""
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
|
||||||
payload = jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["iat"] == 1673784000
|
|
||||||
assert payload["exp"] == 1673787600
|
|
||||||
assert payload["user_id"] == "test-user-id"
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_exp_is_now_plus_expiration_seconds(mock_user):
|
|
||||||
"""exp should equal iat + expiration_seconds exactly."""
|
|
||||||
service = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=900,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
result = service.generate_jwt(mock_user, scope="read")
|
|
||||||
payload = service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["exp"] - payload["iat"] == 900
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_iss_included_when_set(jwt_service, mock_user):
|
|
||||||
"""iss should be present in payload when issuer is non-empty."""
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
|
||||||
payload = jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["iss"] == "test-issuer"
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_aud_included_when_set(jwt_service, mock_user):
|
|
||||||
"""aud should be present in payload when audience is non-empty."""
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
|
||||||
payload = jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["aud"] == "test-audience"
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_iss_absent_when_empty(mock_user):
|
|
||||||
"""iss should be absent from payload when issuer is empty string."""
|
|
||||||
|
|
||||||
service = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="",
|
|
||||||
audience="",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
result = service.generate_jwt(mock_user, scope="read")
|
|
||||||
payload = pyjwt.decode(
|
|
||||||
result["access_token"],
|
|
||||||
"test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithms=["HS256"],
|
|
||||||
options={"verify_aud": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "iss" not in payload
|
|
||||||
assert "aud" not in payload
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_iss_absent_when_none(mock_user):
|
|
||||||
"""iss should be absent from payload when issuer is None."""
|
|
||||||
|
|
||||||
service = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer=None,
|
|
||||||
audience=None,
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
result = service.generate_jwt(mock_user, scope="read")
|
|
||||||
payload = pyjwt.decode(
|
|
||||||
result["access_token"],
|
|
||||||
"test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithms=["HS256"],
|
|
||||||
options={"verify_aud": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "iss" not in payload
|
|
||||||
assert "aud" not in payload
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_scope_absent_from_payload_when_empty(jwt_service, mock_user):
|
|
||||||
"""scope should be absent from payload when not provided."""
|
|
||||||
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="")
|
|
||||||
payload = pyjwt.decode(
|
|
||||||
result["access_token"],
|
|
||||||
"test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithms=["HS256"],
|
|
||||||
issuer="test-issuer",
|
|
||||||
audience="test-audience",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "scope" not in payload
|
|
||||||
|
|
||||||
|
|
||||||
# -- generate_jwt / extra_payload handling --
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_extra_payload_none_does_not_crash(jwt_service, mock_user):
|
|
||||||
"""extra_payload=None should not crash and produce a valid token."""
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload=None)
|
|
||||||
payload = jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["user_id"] == "test-user-id"
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_extra_payload_non_colliding_keys_preserved(
|
|
||||||
jwt_service, mock_user
|
|
||||||
):
|
|
||||||
"""Non-colliding extra_payload keys should appear in decoded token."""
|
|
||||||
result = jwt_service.generate_jwt(
|
|
||||||
mock_user,
|
|
||||||
scope="read",
|
|
||||||
extra_payload={"client_id": "my-app", "delegated": True},
|
|
||||||
)
|
|
||||||
payload = jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["client_id"] == "my-app"
|
|
||||||
assert payload["delegated"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_extra_payload_colliding_iat_overwritten(jwt_service, mock_user):
|
|
||||||
"""iat in extra_payload should be overwritten by the service."""
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload={"iat": 0})
|
|
||||||
payload = jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["iat"] == 1673784000
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_extra_payload_colliding_exp_overwritten(jwt_service, mock_user):
|
|
||||||
"""exp in extra_payload should be overwritten by the service."""
|
|
||||||
result = jwt_service.generate_jwt(
|
|
||||||
mock_user, scope="read", extra_payload={"exp": 9999999999}
|
|
||||||
)
|
|
||||||
payload = jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["exp"] == 1673787600
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_extra_payload_colliding_user_id_overwritten(
|
|
||||||
jwt_service, mock_user
|
|
||||||
):
|
|
||||||
"""user_id in extra_payload should be overwritten by the service."""
|
|
||||||
result = jwt_service.generate_jwt(
|
|
||||||
mock_user, scope="read", extra_payload={"user_id": "hacked"}
|
|
||||||
)
|
|
||||||
payload = jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["user_id"] == "test-user-id"
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_extra_payload_not_mutated(jwt_service, mock_user):
|
|
||||||
"""generate_jwt should not mutate the original extra_payload dict."""
|
|
||||||
extra = {"client_id": "my-app"}
|
|
||||||
jwt_service.generate_jwt(mock_user, scope="read", extra_payload=extra)
|
|
||||||
|
|
||||||
assert extra == {"client_id": "my-app"}
|
|
||||||
|
|
||||||
|
|
||||||
# -- generate_jwt / user.id casting --
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
|
||||||
def test_generate_jwt_user_id_cast_from_uuid(jwt_service):
|
|
||||||
"""user.id as UUID should be cast to str in payload."""
|
|
||||||
user = mock.Mock()
|
|
||||||
user.id = uuid.UUID("12345678-1234-5678-1234-567812345678")
|
|
||||||
result = jwt_service.generate_jwt(user, scope="read")
|
|
||||||
payload = jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["user_id"] == "12345678-1234-5678-1234-567812345678"
|
|
||||||
|
|
||||||
|
|
||||||
# -- decode_jwt / Happy path --
|
|
||||||
|
|
||||||
|
|
||||||
def test_decode_jwt_roundtrip(jwt_service, mock_user):
|
|
||||||
"""Valid token should decode to correct payload."""
|
|
||||||
with freeze_time("2023-01-15 12:00:00"):
|
|
||||||
result = jwt_service.generate_jwt(
|
|
||||||
mock_user, scope="read", extra_payload={"client_id": "my-app"}
|
|
||||||
)
|
|
||||||
|
|
||||||
with freeze_time("2023-01-15 12:30:00"):
|
|
||||||
payload = jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
assert payload["user_id"] == "test-user-id"
|
|
||||||
assert payload["scope"] == "read"
|
|
||||||
assert payload["client_id"] == "my-app"
|
|
||||||
assert payload["iss"] == "test-issuer"
|
|
||||||
assert payload["aud"] == "test-audience"
|
|
||||||
|
|
||||||
|
|
||||||
# -- decode_jwt / Error mapping --
|
|
||||||
|
|
||||||
|
|
||||||
def test_decode_jwt_expired_raises_token_expired_error(jwt_service, mock_user):
|
|
||||||
"""Expired token should raise TokenExpiredError."""
|
|
||||||
with freeze_time("2023-01-15 12:00:00"):
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
|
||||||
|
|
||||||
with freeze_time("2099-01-01 00:00:00"):
|
|
||||||
with pytest.raises(TokenExpiredError):
|
|
||||||
jwt_service.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_decode_jwt_wrong_issuer_raises_token_invalid_error(mock_user):
|
|
||||||
"""Token with wrong issuer should raise TokenInvalidError."""
|
|
||||||
service_a = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer-a",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
service_b = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer-b",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
result = service_a.generate_jwt(mock_user, scope="read")
|
|
||||||
|
|
||||||
with pytest.raises(TokenInvalidError):
|
|
||||||
service_b.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_decode_jwt_wrong_audience_raises_token_invalid_error(mock_user):
|
|
||||||
"""Token with wrong audience should raise TokenInvalidError."""
|
|
||||||
service_a = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience-a",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
service_b = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience-b",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
result = service_a.generate_jwt(mock_user, scope="read")
|
|
||||||
|
|
||||||
with pytest.raises(TokenInvalidError):
|
|
||||||
service_b.decode_jwt(result["access_token"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_decode_jwt_tampered_signature_raises_token_decode_error(
|
|
||||||
jwt_service, mock_user
|
|
||||||
):
|
|
||||||
"""Token with tampered signature should raise TokenDecodeError."""
|
|
||||||
result = jwt_service.generate_jwt(mock_user, scope="read")
|
|
||||||
header, payload, _ = result["access_token"].split(".")
|
|
||||||
tampered_token = f"{header}.{payload}.invalidsignature"
|
|
||||||
|
|
||||||
with pytest.raises(TokenDecodeError):
|
|
||||||
jwt_service.decode_jwt(tampered_token)
|
|
||||||
|
|
||||||
|
|
||||||
def test_decode_jwt_garbage_string_raises_token_decode_error(jwt_service):
|
|
||||||
"""Garbage string should raise TokenDecodeError."""
|
|
||||||
with pytest.raises(TokenDecodeError):
|
|
||||||
jwt_service.decode_jwt("this.is.not.a.valid.token")
|
|
||||||
|
|
||||||
|
|
||||||
def test_decode_jwt_empty_string_raises_token_decode_error(jwt_service):
|
|
||||||
"""Empty string should raise TokenDecodeError."""
|
|
||||||
with pytest.raises(TokenDecodeError):
|
|
||||||
jwt_service.decode_jwt("")
|
|
||||||
|
|
||||||
|
|
||||||
def test_decode_jwt_none_raises_token_decode_error(jwt_service):
|
|
||||||
"""None should raise TokenDecodeError."""
|
|
||||||
with pytest.raises(TokenDecodeError):
|
|
||||||
jwt_service.decode_jwt(None)
|
|
||||||
|
|
||||||
|
|
||||||
def test_algorithm_mismatch_raises_token_decode_error(mock_user):
|
|
||||||
"""Token encoded with HS256 decoded expecting RS256 should raise TokenDecodeError."""
|
|
||||||
service_hs256 = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="HS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
service_rs256 = JwtTokenService(
|
|
||||||
secret_key="test-secret-padded-to-32-bytes!!",
|
|
||||||
algorithm="RS256",
|
|
||||||
issuer="issuer",
|
|
||||||
audience="audience",
|
|
||||||
expiration_seconds=3600,
|
|
||||||
token_type="Bearer",
|
|
||||||
)
|
|
||||||
result = service_hs256.generate_jwt(mock_user, scope="read")
|
|
||||||
|
|
||||||
with pytest.raises(TokenDecodeError):
|
|
||||||
service_rs256.decode_jwt(result["access_token"])
|
|
||||||
@@ -713,7 +713,7 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
|
|||||||
}
|
}
|
||||||
token = jwt.encode(
|
token = jwt.encode(
|
||||||
payload,
|
payload,
|
||||||
"invalid-private-key-padded-to-32b!",
|
"invalid-private-key",
|
||||||
algorithm=settings.APPLICATION_JWT_ALG,
|
algorithm=settings.APPLICATION_JWT_ALG,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ def generate_token(
|
|||||||
if color is None:
|
if color is None:
|
||||||
color = generate_color(identity)
|
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 = (
|
token = (
|
||||||
AccessToken(
|
AccessToken(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
@@ -120,9 +126,7 @@ def generate_token(
|
|||||||
.with_grants(video_grants)
|
.with_grants(video_grants)
|
||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(username or default_username)
|
.with_name(username or default_username)
|
||||||
.with_attributes(
|
.with_attributes(attributes)
|
||||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|||||||
@@ -561,12 +561,12 @@ class Base(Configuration):
|
|||||||
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
|
||||||
)
|
)
|
||||||
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
||||||
default=["given_name", "usual_name"],
|
default=["first_name", "last_name"],
|
||||||
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
||||||
default="given_name",
|
default="first_name",
|
||||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
@@ -808,6 +808,13 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
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
|
# External Applications
|
||||||
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
|
||||||
40,
|
40,
|
||||||
@@ -981,13 +988,7 @@ class Test(Base):
|
|||||||
USE_SWAGGER = True
|
USE_SWAGGER = True
|
||||||
EXTERNAL_API_ENABLED = True
|
EXTERNAL_API_ENABLED = True
|
||||||
|
|
||||||
LIVEKIT_CONFIGURATION = {
|
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
|
||||||
"api_key": "devkey-padded-for-minimum-len!-livekit",
|
|
||||||
"api_secret": "secret-key-padded-for-minimum-len!-livekit",
|
|
||||||
"url": "http://127.0.0.1.nip.io:7880",
|
|
||||||
}
|
|
||||||
|
|
||||||
APPLICATION_JWT_SECRET_KEY = "secret-key-padded-for-minimum-len!-application" # noqa:S105
|
|
||||||
APPLICATION_JWT_AUDIENCE = "Test inc."
|
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||||
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = True
|
CELERY_TASK_ALWAYS_EAGER = True
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.13.0"
|
version = "1.12.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -55,7 +55,7 @@ dependencies = [
|
|||||||
"PyJWT==2.12.1",
|
"PyJWT==2.12.1",
|
||||||
"python-frontmatter==1.1.0",
|
"python-frontmatter==1.1.0",
|
||||||
"python-magic==0.4.27",
|
"python-magic==0.4.27",
|
||||||
"requests==2.33.0",
|
"requests==2.32.5",
|
||||||
"sentry-sdk==2.54.0",
|
"sentry-sdk==2.54.0",
|
||||||
"whitenoise==6.12.0",
|
"whitenoise==6.12.0",
|
||||||
"mozilla-django-oidc==5.0.2",
|
"mozilla-django-oidc==5.0.2",
|
||||||
|
|||||||
Generated
+49
-49
@@ -474,55 +474,55 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "46.0.6"
|
version = "46.0.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
|
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1157,7 +1157,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.13.0"
|
version = "1.12.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -1255,7 +1255,7 @@ requires-dist = [
|
|||||||
{ name = "python-frontmatter", specifier = "==1.1.0" },
|
{ name = "python-frontmatter", specifier = "==1.1.0" },
|
||||||
{ name = "python-magic", specifier = "==0.4.27" },
|
{ name = "python-magic", specifier = "==0.4.27" },
|
||||||
{ name = "redis", specifier = "==5.2.1" },
|
{ name = "redis", specifier = "==5.2.1" },
|
||||||
{ name = "requests", specifier = "==2.33.0" },
|
{ name = "requests", specifier = "==2.32.5" },
|
||||||
{ name = "sentry-sdk", specifier = "==2.54.0" },
|
{ name = "sentry-sdk", specifier = "==2.54.0" },
|
||||||
{ name = "whitenoise", specifier = "==6.12.0" },
|
{ name = "whitenoise", specifier = "==6.12.0" },
|
||||||
]
|
]
|
||||||
@@ -1965,7 +1965,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.33.0"
|
version = "2.32.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
@@ -1973,9 +1973,9 @@ dependencies = [
|
|||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:20-alpine AS frontend-deps
|
||||||
|
|
||||||
USER node
|
|
||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
COPY ./src/frontend/package.json ./package.json
|
COPY ./src/frontend/package.json ./package.json
|
||||||
@@ -10,7 +8,7 @@ COPY ./src/frontend/package-lock.json ./package-lock.json
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY .dockerignore ./.dockerignore
|
COPY .dockerignore ./.dockerignore
|
||||||
COPY --chown=node:node ./src/frontend/ .
|
COPY ./src/frontend/ .
|
||||||
|
|
||||||
### ---- Front-end builder image ----
|
### ---- Front-end builder image ----
|
||||||
FROM frontend-deps AS meet
|
FROM frontend-deps AS meet
|
||||||
@@ -19,8 +17,6 @@ WORKDIR /home/frontend
|
|||||||
|
|
||||||
FROM frontend-deps AS meet-dev
|
FROM frontend-deps AS meet-dev
|
||||||
|
|
||||||
USER node
|
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
Generated
+872
-3
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.13.0",
|
"version": "1.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
||||||
"format": "prettier --write ./src",
|
"format": "prettier --write ./src",
|
||||||
"check": "prettier --check ./src"
|
"check": "prettier --check ./src",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||||
@@ -59,10 +61,12 @@
|
|||||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||||
"eslint-plugin-react-hooks": "5.2.0",
|
"eslint-plugin-react-hooks": "5.2.0",
|
||||||
"eslint-plugin-react-refresh": "0.4.20",
|
"eslint-plugin-react-refresh": "0.4.20",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.1",
|
||||||
"vite-tsconfig-paths": "6.1.1"
|
"vite-tsconfig-paths": "6.1.1",
|
||||||
|
"vitest": "^4.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,11 +274,6 @@ const config: Config = {
|
|||||||
min: { value: 'min-content' },
|
min: { value: 'min-content' },
|
||||||
max: { value: 'max-content' },
|
max: { value: 'max-content' },
|
||||||
fit: { value: 'fit-content' },
|
fit: { value: 'fit-content' },
|
||||||
// room layout
|
|
||||||
'room-side-panel': { value: '360px' },
|
|
||||||
'room-side-panel-margin': { value: '1.5rem' },
|
|
||||||
'room-control-bar': { value: '80px' },
|
|
||||||
'room-reaction-toolbar-height': { value: '42px' },
|
|
||||||
},
|
},
|
||||||
spacing,
|
spacing,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export interface ApiConfig {
|
|||||||
enable_firefox_proxy_workaround: boolean
|
enable_firefox_proxy_workaround: boolean
|
||||||
default_sources: string[]
|
default_sources: string[]
|
||||||
}
|
}
|
||||||
|
encryption?: {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const Avatar = ({
|
|||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
const initial = name?.trim()?.charAt(0) ?? ''
|
const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? ''
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -70,7 +70,7 @@ export const Avatar = ({
|
|||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={css({
|
className={css({
|
||||||
marginTop: '-0.3rem',
|
lineHeight: 1,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{initial}
|
{initial}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { BackendLanguage } from '@/utils/languages'
|
|||||||
export type ApiUser = {
|
export type ApiUser = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
full_name: string
|
full_name: string | null
|
||||||
|
short_name: string | null
|
||||||
last_name: string
|
last_name: string
|
||||||
language: BackendLanguage
|
language: BackendLanguage
|
||||||
timezone: string
|
timezone: string
|
||||||
|
default_encryption: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { type ApiUser } from './ApiUser'
|
import { type ApiUser } from './ApiUser'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
|
||||||
export type ApiUserPreferences = Pick<ApiUser, 'id' | 'timezone' | 'language'>
|
export type ApiUserPreferences = Partial<
|
||||||
|
Pick<ApiUser, 'timezone' | 'language' | 'default_encryption'>
|
||||||
|
> & { id: string }
|
||||||
|
|
||||||
export const updateUserPreferences = async ({
|
export const updateUserPreferences = async ({
|
||||||
user,
|
user,
|
||||||
}: {
|
}: {
|
||||||
user: ApiUserPreferences
|
user: ApiUserPreferences
|
||||||
}): Promise<ApiUser> => {
|
}): Promise<ApiUser> => {
|
||||||
return await fetchApi(`/users/${user.id}/`, {
|
const { id, ...payload } = user
|
||||||
method: 'PUT',
|
return await fetchApi(`/users/${id}/`, {
|
||||||
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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'
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 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(/^#/, '')
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
|
import { EncryptionStatusContext } from './encryptionStatusContextValue'
|
||||||
|
|
||||||
|
export const useEncryptionStatus = () => useContext(EncryptionStatusContext)
|
||||||
@@ -1,35 +1,146 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
|
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
import { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { isRoomValid } from '@/features/rooms'
|
import { isRoomValid } from '@/features/rooms'
|
||||||
|
import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid'
|
||||||
|
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||||
|
|
||||||
export const JoinMeetingDialog = () => {
|
export const JoinMeetingDialog = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
|
const [step, setStep] = useState<'room' | 'passphrase'>('room')
|
||||||
|
const [roomId, setRoomId] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => {
|
const parseInput = (input: string): { roomId: string; hash: string } => {
|
||||||
const roomId = (data.roomId as string)
|
const trimmed = input.trim()
|
||||||
.trim()
|
try {
|
||||||
.replace(`${window.location.origin}/`, '')
|
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()
|
||||||
navigateTo('room', roomId)
|
navigateTo('room', roomId)
|
||||||
|
window.location.hash = passphrase
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateRoomId = (value: string) => {
|
const validateRoomId = (value: string) => {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
return !isRoomValid(trimmed) ? (
|
const { roomId: id } = parseInput(trimmed)
|
||||||
|
return !isRoomValid(id) ? (
|
||||||
<>
|
<>
|
||||||
<p>{t('joinInputError')}</p>
|
<p>{t('joinInputError')}</p>
|
||||||
<Ul>
|
<Ul>
|
||||||
<li>{window.location.origin}/uio-azer-jkl</li>
|
<li>{window.location.origin}/uio-azer-jkl</li>
|
||||||
<li>uio-azer-jkl</li>
|
<li>uio-azer-jkl</li>
|
||||||
|
<li>uioazerjkl</li>
|
||||||
</Ul>
|
</Ul>
|
||||||
</>
|
</>
|
||||||
) : null
|
) : 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 (
|
return (
|
||||||
<Dialog title={t('joinMeeting')}>
|
<Dialog title={t('joinMeeting')}>
|
||||||
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}>
|
<Form onSubmit={handleRoomSubmit} submitLabel={isLoading ? '...' : t('joinInputSubmit')}>
|
||||||
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
|
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
|
||||||
<Field
|
<Field
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRo
|
|||||||
// fixme - duplication with the InviteDialog
|
// fixme - duplication with the InviteDialog
|
||||||
export const LaterMeetingDialog = ({
|
export const LaterMeetingDialog = ({
|
||||||
room,
|
room,
|
||||||
|
hash,
|
||||||
...dialogProps
|
...dialogProps
|
||||||
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
}: { room: null | ApiRoom; hash?: string } & Omit<DialogProps, 'title'>) => {
|
||||||
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
||||||
|
|
||||||
const roomUrl = room && getRouteUrl('room', room?.slug)
|
const roomUrl = room ? `${getRouteUrl('room', room.slug)}${hash ? `#${hash}` : ''}` : null
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
@@ -31,7 +32,7 @@ export const LaterMeetingDialog = ({
|
|||||||
copyRoomToClipboard,
|
copyRoomToClipboard,
|
||||||
isRoomUrlCopied,
|
isRoomUrlCopied,
|
||||||
copyRoomUrlToClipboard,
|
copyRoomUrlToClipboard,
|
||||||
} = useCopyRoomToClipboard(room || undefined)
|
} = useCopyRoomToClipboard(room || undefined, hash)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
|
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useUser, UserAware } from '@/features/auth'
|
|||||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||||
import { RiAddLine, RiLink } from '@remixicon/react'
|
import { RiAddLine, RiLink } from '@remixicon/react'
|
||||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||||
|
import { generatePassphrase } from '@/features/encryption'
|
||||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||||
import { ReactNode, useEffect, useState } from 'react'
|
import { ReactNode, useEffect, useState } from 'react'
|
||||||
@@ -148,17 +149,27 @@ const IntroText = styled('div', {
|
|||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { isLoggedIn } = useUser()
|
const { isLoggedIn, user } = useUser()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userChoices: { username },
|
userChoices: { username },
|
||||||
} = usePersistentUserChoices()
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom; hash?: string }>(null)
|
||||||
const [redirectFailed, setRedirectFailed] = useState(false)
|
const [redirectFailed, setRedirectFailed] = useState(false)
|
||||||
|
|
||||||
const { data } = useConfig()
|
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(() => {
|
useEffect(() => {
|
||||||
const checkSiteAndRedirect = async () => {
|
const checkSiteAndRedirect = async () => {
|
||||||
@@ -210,12 +221,17 @@ export const Home = () => {
|
|||||||
menuRecipe({ icon: true, variant: 'light' }).item
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
}
|
}
|
||||||
onAction={async () => {
|
onAction={async () => {
|
||||||
const slug = generateRoomId()
|
const { room, hash } = await buildRoomBundle()
|
||||||
createRoom({ slug, username }).then((data) =>
|
navigateTo('room', room.slug, {
|
||||||
navigateTo('room', data.slug, {
|
state: { create: true, initialRoomData: room },
|
||||||
state: { create: true, initialRoomData: data },
|
})
|
||||||
})
|
if (hash) {
|
||||||
)
|
window.history.replaceState(
|
||||||
|
window.history.state,
|
||||||
|
'',
|
||||||
|
`${window.location.pathname}#${hash}`
|
||||||
|
)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
data-attr="create-option-instant"
|
data-attr="create-option-instant"
|
||||||
>
|
>
|
||||||
@@ -226,11 +242,9 @@ export const Home = () => {
|
|||||||
className={
|
className={
|
||||||
menuRecipe({ icon: true, variant: 'light' }).item
|
menuRecipe({ icon: true, variant: 'light' }).item
|
||||||
}
|
}
|
||||||
onAction={() => {
|
onAction={async () => {
|
||||||
const slug = generateRoomId()
|
const { room, hash } = await buildRoomBundle()
|
||||||
createRoom({ slug, username }).then((data) =>
|
setLaterRoom({ room, hash })
|
||||||
setLaterRoom(data)
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
data-attr="create-option-later"
|
data-attr="create-option-later"
|
||||||
>
|
>
|
||||||
@@ -265,7 +279,8 @@ export const Home = () => {
|
|||||||
</RightColumn>
|
</RightColumn>
|
||||||
</Columns>
|
</Columns>
|
||||||
<LaterMeetingDialog
|
<LaterMeetingDialog
|
||||||
room={laterRoom}
|
room={laterRoom?.room ?? null}
|
||||||
|
hash={laterRoom?.hash}
|
||||||
onOpenChange={() => setLaterRoom(null)}
|
onOpenChange={() => setLaterRoom(null)}
|
||||||
/>
|
/>
|
||||||
</Screen>
|
</Screen>
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { cva, RecipeVariantProps } from '@/styled-system/css'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const controlBarRegion = cva({
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flex: '1 1 33%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '0.65rem',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
mobile: {
|
|
||||||
true: {
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
width: '330px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
mobile: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export type ControlBarRegionProps = React.HTMLAttributes<HTMLDivElement> &
|
|
||||||
RecipeVariantProps<typeof controlBarRegion>
|
|
||||||
|
|
||||||
export function ControlBarRegion({
|
|
||||||
children,
|
|
||||||
mobile,
|
|
||||||
...props
|
|
||||||
}: ControlBarRegionProps) {
|
|
||||||
const { t } = useTranslation('rooms')
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="region"
|
|
||||||
aria-label={t('controls.region')}
|
|
||||||
className={controlBarRegion({ mobile })}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// RoomContentArea.tsx
|
|
||||||
|
|
||||||
import React from 'react'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { cva } from '@/styled-system/css'
|
|
||||||
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
|
||||||
import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
|
||||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
|
||||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
|
||||||
|
|
||||||
const RoomViewport = styled(
|
|
||||||
'div',
|
|
||||||
cva({
|
|
||||||
base: {
|
|
||||||
position: 'absolute',
|
|
||||||
maxHeight: '100%',
|
|
||||||
transition:
|
|
||||||
'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms, padding .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
isSidePanelOpen: {
|
|
||||||
true: {
|
|
||||||
inset: `var(--lk-grid-gap) calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin) * 2) calc(var(--sizes-room-control-bar)) 16px`,
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
inset: `var(--lk-grid-gap) var(--lk-grid-gap) calc(var(--sizes-room-control-bar))`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
isReactionToolbarOpen: {
|
|
||||||
true: {
|
|
||||||
paddingBottom:
|
|
||||||
'calc(var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))',
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
paddingBottom: '0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const TrackAreaContainer = styled(
|
|
||||||
'div',
|
|
||||||
cva({
|
|
||||||
base: {
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
width: '100%',
|
|
||||||
transition: 'height .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
areSubtitlesOpen: {
|
|
||||||
true: {
|
|
||||||
height: 'calc(100% - 12rem)',
|
|
||||||
},
|
|
||||||
false: {
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
interface RoomContentAreaProps {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoomContentArea({ children }: RoomContentAreaProps) {
|
|
||||||
const { isSidePanelOpen } = useSidePanel()
|
|
||||||
const { areSubtitlesOpen } = useSubtitles()
|
|
||||||
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RoomViewport
|
|
||||||
isSidePanelOpen={isSidePanelOpen}
|
|
||||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
|
||||||
>
|
|
||||||
<TrackAreaContainer areSubtitlesOpen={areSubtitlesOpen}>
|
|
||||||
{children}
|
|
||||||
</TrackAreaContainer>
|
|
||||||
<Subtitles />
|
|
||||||
<MainNotificationToast />
|
|
||||||
</RoomViewport>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||||
@@ -10,11 +10,17 @@ import { decodeNotificationDataReceived } from './utils'
|
|||||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||||
|
import {
|
||||||
|
Emoji,
|
||||||
|
Reaction,
|
||||||
|
} from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||||
|
import {
|
||||||
|
ANIMATION_DURATION,
|
||||||
|
ReactionPortals,
|
||||||
|
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||||
import { layoutStore } from '@/stores/layout'
|
import { layoutStore } from '@/stores/layout'
|
||||||
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { Emoji } from '@/features/reactions/types'
|
|
||||||
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
|
||||||
|
|
||||||
export const MainNotificationToast = () => {
|
export const MainNotificationToast = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
@@ -22,7 +28,8 @@ export const MainNotificationToast = () => {
|
|||||||
const { t } = useTranslation('notifications')
|
const { t } = useTranslation('notifications')
|
||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const { appendReaction } = useReactions()
|
const [reactions, setReactions] = useState<Reaction[]>([])
|
||||||
|
const instanceIdRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChatMessage = (
|
const handleChatMessage = (
|
||||||
@@ -55,13 +62,21 @@ export const MainNotificationToast = () => {
|
|||||||
}
|
}
|
||||||
}, [room, triggerNotificationSound, announce, t])
|
}, [room, triggerNotificationSound, announce, t])
|
||||||
|
|
||||||
const handleEmoji = useCallback(
|
const handleEmoji = (emoji: string, participant: Participant) => {
|
||||||
(emoji: string, participant: Participant) => {
|
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
|
||||||
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
|
const id = instanceIdRef.current++
|
||||||
appendReaction(emoji as Emoji, participant)
|
setReactions((prev) => [
|
||||||
},
|
...prev,
|
||||||
[appendReaction]
|
{
|
||||||
)
|
id,
|
||||||
|
emoji,
|
||||||
|
participant,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
setTimeout(() => {
|
||||||
|
setReactions((prev) => prev.filter((instance) => instance.id !== id))
|
||||||
|
}, ANIMATION_DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDataReceived = (
|
const handleDataReceived = (
|
||||||
@@ -134,7 +149,7 @@ export const MainNotificationToast = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||||
}
|
}
|
||||||
}, [room, handleEmoji])
|
}, [room])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const showJoinNotification = (participant: Participant) => {
|
const showJoinNotification = (participant: Participant) => {
|
||||||
@@ -237,6 +252,7 @@ export const MainNotificationToast = () => {
|
|||||||
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
<WaitingParticipantNotification />
|
<WaitingParticipantNotification />
|
||||||
|
<ReactionPortals reactions={reactions} />
|
||||||
</Div>
|
</Div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-6
@@ -34,7 +34,6 @@ export const WaitingParticipantNotification = () => {
|
|||||||
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Show notification when the first participant enters the waiting room
|
|
||||||
if (
|
if (
|
||||||
!isParticipantListEmpty(waitingParticipants) &&
|
!isParticipantListEmpty(waitingParticipants) &&
|
||||||
isParticipantListEmpty(prevWaitingParticipant) &&
|
isParticipantListEmpty(prevWaitingParticipant) &&
|
||||||
@@ -49,10 +48,9 @@ export const WaitingParticipantNotification = () => {
|
|||||||
}
|
}
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
setShowQuickActionsMessage(false)
|
setShowQuickActionsMessage(false)
|
||||||
timerRef.current = null // Clear the ref when timeout completes
|
timerRef.current = null
|
||||||
}, NOTIFICATION_DISPLAY_DURATION)
|
}, NOTIFICATION_DISPLAY_DURATION)
|
||||||
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
|
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
|
||||||
// Hide notification when the participant count changes
|
|
||||||
setShowQuickActionsMessage(false)
|
setShowQuickActionsMessage(false)
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -63,7 +61,6 @@ export const WaitingParticipantNotification = () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// This cleanup function will only run when the component unmounts
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current !== null) {
|
if (timerRef.current !== null) {
|
||||||
clearTimeout(timerRef.current)
|
clearTimeout(timerRef.current)
|
||||||
@@ -72,7 +69,6 @@ export const WaitingParticipantNotification = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Hide notification when participants panel is opened
|
|
||||||
if (isParticipantsOpen) {
|
if (isParticipantsOpen) {
|
||||||
setShowQuickActionsMessage(false)
|
setShowQuickActionsMessage(false)
|
||||||
}
|
}
|
||||||
@@ -100,7 +96,7 @@ export const WaitingParticipantNotification = () => {
|
|||||||
>
|
>
|
||||||
{t('one')}
|
{t('one')}
|
||||||
</Text>
|
</Text>
|
||||||
<HStack gap="1rem">
|
<HStack gap="0.5rem">
|
||||||
<Avatar
|
<Avatar
|
||||||
name={waitingParticipants[0].username}
|
name={waitingParticipants[0].username}
|
||||||
bgColor={waitingParticipants[0].color}
|
bgColor={waitingParticipants[0].color}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { RiEmotionLine } from '@remixicon/react'
|
|
||||||
import { ToggleButton } from '@/primitives'
|
|
||||||
|
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
|
||||||
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
|
||||||
|
|
||||||
export const ReactionsToggle = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
|
|
||||||
const { isOpen, toggle } = useReactionsToolbar()
|
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
|
||||||
id: 'reaction',
|
|
||||||
handler: toggle,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToggleButton
|
|
||||||
id="reactions-toggle"
|
|
||||||
data-attr="reactions-toggle"
|
|
||||||
square
|
|
||||||
variant="primaryDark"
|
|
||||||
aria-label={t('button')}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
tooltip={t('button')}
|
|
||||||
isSelected={isOpen}
|
|
||||||
onChange={toggle}
|
|
||||||
>
|
|
||||||
<RiEmotionLine />
|
|
||||||
</ToggleButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Emoji } from '../../types'
|
|
||||||
import { useReactions } from '../../hooks/useReactions'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
|
|
||||||
export const ReactionButton = ({ emoji }: { emoji: Emoji }) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
const { sendReaction } = useReactions()
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onPress={() => sendReaction(emoji)}
|
|
||||||
aria-label={t(`emojis.${emoji}`)}
|
|
||||||
variant="primaryTextDark"
|
|
||||||
size="sm"
|
|
||||||
round
|
|
||||||
data-attr={`send-reaction-${emoji}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`/assets/reactions/${emoji}.png`}
|
|
||||||
alt=""
|
|
||||||
className={css({
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
userSelect: 'none',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { FocusScope, useFocusManager } from '@react-aria/focus'
|
|
||||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
|
||||||
import { ReactionButton } from './ReactionButton'
|
|
||||||
import { Emoji } from '../../types'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
import { getFirstControlBarFocusable } from '@/utils/dom'
|
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
const Container = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 'var(--sizes-room-control-bar)',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const StyledStrip = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.2rem',
|
|
||||||
borderRadius: '21px',
|
|
||||||
padding: '0.15rem',
|
|
||||||
backgroundColor: 'primaryDark.100',
|
|
||||||
opacity: 0,
|
|
||||||
transform: 'translateY(3.25rem)',
|
|
||||||
transition: 'opacity, transform',
|
|
||||||
transitionDuration: '0.5s',
|
|
||||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
isVisible: {
|
|
||||||
true: {
|
|
||||||
opacity: 1,
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
desktopOffset: {
|
|
||||||
true: {
|
|
||||||
// Ideally this value should be calculated dynamically in JavaScript to keep
|
|
||||||
// the reaction toolbar perfectly centered relative to the reaction toggle.
|
|
||||||
// However, for simplicity and to follow a pragmatic 80/20 approach,
|
|
||||||
// this value is currently hardcoded in CSS.
|
|
||||||
marginRight: '30px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const Strip = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const { isOpen } = useReactionsToolbar()
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
// defer one frame so the browser paints opacity:0 first
|
|
||||||
const id = requestAnimationFrame(() => setIsVisible(true))
|
|
||||||
return () => cancelAnimationFrame(id)
|
|
||||||
} else {
|
|
||||||
setIsVisible(false)
|
|
||||||
}
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledStrip
|
|
||||||
ref={ref}
|
|
||||||
aria-hidden={!isOpen}
|
|
||||||
isVisible={isVisible}
|
|
||||||
desktopOffset={!isMobile}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</StyledStrip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
const focusManager = useFocusManager()
|
|
||||||
|
|
||||||
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
|
|
||||||
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
|
|
||||||
if (comingFromOutside) {
|
|
||||||
focusManager?.focusFirst()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowRight':
|
|
||||||
focusManager?.focusNext({ wrap: true })
|
|
||||||
break
|
|
||||||
case 'ArrowLeft':
|
|
||||||
focusManager?.focusPrevious({ wrap: true })
|
|
||||||
break
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault()
|
|
||||||
document.getElementById('reactions-toggle')?.focus()
|
|
||||||
layoutStore.showReactionsToolbar = false
|
|
||||||
break
|
|
||||||
case 'Tab':
|
|
||||||
if (!e.shiftKey) {
|
|
||||||
e.preventDefault()
|
|
||||||
getFirstControlBarFocusable('control-bar')?.focus()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="toolbar"
|
|
||||||
aria-label={t('toolbar')}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onFocus={onFocus}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ReactionsToolbar = () => {
|
|
||||||
const { isOpen } = useReactionsToolbar()
|
|
||||||
const shouldMount = useDelayUnmount(isOpen, 300)
|
|
||||||
|
|
||||||
if (!shouldMount) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
|
|
||||||
<FocusScope autoFocus>
|
|
||||||
<KeyboardNavigation>
|
|
||||||
<Strip>
|
|
||||||
{Object.values(Emoji).map((emoji) => (
|
|
||||||
<ReactionButton key={emoji} emoji={emoji} />
|
|
||||||
))}
|
|
||||||
</Strip>
|
|
||||||
</KeyboardNavigation>
|
|
||||||
</FocusScope>
|
|
||||||
</Container>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export const ANIMATION_DURATION = 3000
|
|
||||||
export const ANIMATION_DISTANCE = 300
|
|
||||||
export const FADE_OUT_THRESHOLD = 0.7
|
|
||||||
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
|
|
||||||
export const INITIAL_POSITION = 200
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { accessibilityStore } from '@/stores/accessibility'
|
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
|
|
||||||
import { getEmojiLabel } from '../utils'
|
|
||||||
import { Reaction } from '../types'
|
|
||||||
|
|
||||||
export const useAnnounceReaction = (latestReaction: Reaction | undefined) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
const { announceReactions } = useSnapshot(accessibilityStore)
|
|
||||||
const [lastAnnouncedId, setLastAnnouncedId] = useState<string | null>(null)
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!announceReactions || !latestReaction) return
|
|
||||||
if (latestReaction.id === lastAnnouncedId) return
|
|
||||||
|
|
||||||
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
|
|
||||||
const participantName = latestReaction.participantName
|
|
||||||
|
|
||||||
announce(t('announce', { name: participantName, emoji: emojiLabel }))
|
|
||||||
setLastAnnouncedId(latestReaction.id)
|
|
||||||
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { useCallback } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { reactionsStore } from '@/stores/reactions'
|
|
||||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
|
||||||
import { useNotifyParticipants } from '@/features/notifications'
|
|
||||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
|
||||||
import { Participant } from 'livekit-client'
|
|
||||||
import { Emoji } from '../types'
|
|
||||||
import { ANIMATION_DURATION } from '../constants'
|
|
||||||
|
|
||||||
export const useReactions = () => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
|
||||||
const { notifyParticipants } = useNotifyParticipants()
|
|
||||||
|
|
||||||
const appendReaction = useCallback(
|
|
||||||
(emoji: Emoji, participant?: Participant) => {
|
|
||||||
const newReaction = {
|
|
||||||
id: `${emoji}-${Date.now()}-${Math.random()}`,
|
|
||||||
emoji,
|
|
||||||
participantName: participant
|
|
||||||
? participant.name || participant.identity
|
|
||||||
: t('you'),
|
|
||||||
isLocal: !participant,
|
|
||||||
}
|
|
||||||
|
|
||||||
reactionsStore.reactions.push(newReaction)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const index = reactionsStore.reactions.findIndex(
|
|
||||||
(r) => r.id === newReaction.id
|
|
||||||
)
|
|
||||||
if (index !== -1) reactionsStore.reactions.splice(index, 1)
|
|
||||||
}, ANIMATION_DURATION)
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
)
|
|
||||||
|
|
||||||
const sendReaction = async (emoji: Emoji) => {
|
|
||||||
appendReaction(emoji)
|
|
||||||
await notifyParticipants({
|
|
||||||
type: NotificationType.ReactionReceived,
|
|
||||||
additionalData: { data: { emoji } },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const debouncedSendReaction = useRateLimiter({
|
|
||||||
callback: sendReaction,
|
|
||||||
maxCalls: 10,
|
|
||||||
windowMs: 1000,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
sendReaction: debouncedSendReaction,
|
|
||||||
appendReaction,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { layoutStore } from '@/stores/layout'
|
|
||||||
|
|
||||||
export const useReactionsToolbar = () => {
|
|
||||||
const layoutSnap = useSnapshot(layoutStore)
|
|
||||||
|
|
||||||
return {
|
|
||||||
isOpen: layoutSnap.showReactionsToolbar,
|
|
||||||
toggle: () => {
|
|
||||||
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export enum Emoji {
|
|
||||||
THUMBS_UP = 'thumbs-up',
|
|
||||||
THUMBS_DOWN = 'thumbs-down',
|
|
||||||
CLAP = 'clapping-hands',
|
|
||||||
HEART = 'red-heart',
|
|
||||||
LAUGHING = 'face-with-tears-of-joy',
|
|
||||||
SURPRISED = 'face-with-open-mouth',
|
|
||||||
CELEBRATION = 'party-popper',
|
|
||||||
PLEASE = 'folded-hands',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Reaction {
|
|
||||||
id: string
|
|
||||||
emoji: Emoji
|
|
||||||
participantName: string
|
|
||||||
isLocal: boolean
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
||||||
import { RecordingStateToast } from './RecordingStateToast'
|
|
||||||
import { ErrorAlertDialog } from './ErrorAlertDialog'
|
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 = () => {
|
export const RecordingProvider = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RecordingStateToast />
|
|
||||||
<LimitReachedAlertDialog />
|
<LimitReachedAlertDialog />
|
||||||
<ErrorAlertDialog />
|
<ErrorAlertDialog />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type ApiRoom = {
|
|||||||
pin_code: string
|
pin_code: string
|
||||||
is_administrable: boolean
|
is_administrable: boolean
|
||||||
access_level: ApiAccessLevel
|
access_level: ApiAccessLevel
|
||||||
|
is_encrypted: boolean
|
||||||
|
encryption_paused: boolean
|
||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
configuration?: {
|
configuration?: {
|
||||||
[key: string]: string | number | boolean | string[]
|
[key: string]: string | number | boolean | string[]
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ export interface CreateRoomParams {
|
|||||||
slug: string
|
slug: string
|
||||||
callbackId?: string
|
callbackId?: string
|
||||||
username?: string
|
username?: string
|
||||||
|
isEncrypted?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const createRoom = ({
|
const createRoom = ({
|
||||||
slug,
|
slug,
|
||||||
callbackId,
|
callbackId,
|
||||||
username = '',
|
username = '',
|
||||||
|
isEncrypted = false,
|
||||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||||
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
|
const queryParams = username ? `?username=${encodeURIComponent(username)}` : ''
|
||||||
|
return fetchApi(`rooms/${queryParams}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: slug,
|
name: slug,
|
||||||
callback_id: callbackId,
|
callback_id: callbackId,
|
||||||
|
is_encrypted: isEncrypted,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type WaitingParticipant = {
|
|||||||
status: string
|
status: string
|
||||||
username: string
|
username: string
|
||||||
color: string
|
color: string
|
||||||
|
is_authenticated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WaitingParticipantsResponse = {
|
export type WaitingParticipantsResponse = {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { ApiError } from '@/api/ApiError'
|
|||||||
|
|
||||||
export type PatchRoomParams = {
|
export type PatchRoomParams = {
|
||||||
roomId: string
|
roomId: string
|
||||||
room: Partial<Pick<ApiRoom, 'configuration' | 'access_level'>>
|
room: Partial<
|
||||||
|
Pick<ApiRoom, 'configuration' | 'access_level' | 'encryption_paused'>
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
|
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
@@ -7,11 +7,21 @@ import {
|
|||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
|
ExternalE2EEKeyProvider,
|
||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
Room,
|
Room,
|
||||||
|
RoomEvent,
|
||||||
RoomOptions,
|
RoomOptions,
|
||||||
VideoPresets,
|
VideoPresets,
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
|
import {
|
||||||
|
generatePassphrase,
|
||||||
|
getPassphraseFromHash,
|
||||||
|
isValidPassphrase,
|
||||||
|
EncryptionStatusProvider,
|
||||||
|
EncryptionMismatchScreen,
|
||||||
|
EncryptionPhase,
|
||||||
|
} from '@/features/encryption'
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
@@ -20,6 +30,7 @@ import { ErrorScreen } from '@/components/ErrorScreen'
|
|||||||
import { fetchRoom } from '../api/fetchRoom'
|
import { fetchRoom } from '../api/fetchRoom'
|
||||||
import { ApiRoom } from '../api/ApiRoom'
|
import { ApiRoom } from '../api/ApiRoom'
|
||||||
import { useCreateRoom } from '../api/createRoom'
|
import { useCreateRoom } from '../api/createRoom'
|
||||||
|
import { usePatchRoom } from '../api/patchRoom'
|
||||||
import { InviteDialog } from './InviteDialog'
|
import { InviteDialog } from './InviteDialog'
|
||||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
@@ -86,12 +97,85 @@ export const Conference = ({
|
|||||||
retry: false,
|
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 roomOptions = useMemo((): RoomOptions => {
|
||||||
return {
|
const baseOptions: RoomOptions = {
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
videoCodec: 'vp9',
|
// 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,
|
||||||
},
|
},
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||||
@@ -106,8 +190,25 @@ export const Conference = ({
|
|||||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
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
|
// 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.videoDeviceId,
|
||||||
userConfig.videoPublishResolution,
|
userConfig.videoPublishResolution,
|
||||||
userConfig.audioDeviceId,
|
userConfig.audioDeviceId,
|
||||||
@@ -116,62 +217,6 @@ export const Conference = ({
|
|||||||
|
|
||||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
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
|
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||||
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
||||||
@@ -186,9 +231,198 @@ export const Conference = ({
|
|||||||
return livekit_url
|
return livekit_url
|
||||||
}, [apiConfig?.livekit])
|
}, [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')
|
const { t } = useTranslation('rooms')
|
||||||
if (isCreateError) {
|
if (isCreateError) {
|
||||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
|
||||||
return (
|
return (
|
||||||
<ErrorScreen
|
<ErrorScreen
|
||||||
title={t('error.createRoom.heading')}
|
title={t('error.createRoom.heading')}
|
||||||
@@ -197,11 +431,14 @@ export const Conference = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (encryptionMismatch) {
|
||||||
|
return <EncryptionMismatchScreen reason={encryptionMismatch} />
|
||||||
|
}
|
||||||
|
|
||||||
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
||||||
// These settings help ensure successful connections in poor network conditions
|
|
||||||
const connectOptions = {
|
const connectOptions = {
|
||||||
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
maxRetries: 5,
|
||||||
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
peerConnectionTimeout: 60000,
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -211,7 +448,7 @@ export const Conference = ({
|
|||||||
room={room}
|
room={room}
|
||||||
serverUrl={serverUrl}
|
serverUrl={serverUrl}
|
||||||
token={data?.livekit?.token}
|
token={data?.livekit?.token}
|
||||||
connect={isConnectionWarmedUp}
|
connect={isConnectionWarmedUp && encryptionSetupComplete}
|
||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={
|
video={
|
||||||
userConfig.videoEnabled && {
|
userConfig.videoEnabled && {
|
||||||
@@ -250,7 +487,13 @@ export const Conference = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VideoConference />
|
<EncryptionStatusProvider
|
||||||
|
isEncrypted={isEncrypted}
|
||||||
|
onPhaseChange={handlePhaseChange}
|
||||||
|
setServerEncryptionPaused={setServerEncryptionPaused}
|
||||||
|
>
|
||||||
|
<VideoConference />
|
||||||
|
</EncryptionStatusProvider>
|
||||||
{showInviteDialog && !isMobile && (
|
{showInviteDialog && !isMobile && (
|
||||||
<InviteDialog
|
<InviteDialog
|
||||||
isOpen={showInviteDialog}
|
isOpen={showInviteDialog}
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
|
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
const roomUrl = getRouteUrl('room', roomData?.slug)
|
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 telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,13 @@ import { queryClient } from '@/api/queryClient'
|
|||||||
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||||
|
import {
|
||||||
|
isValidPassphrase,
|
||||||
|
getPassphraseFromHash,
|
||||||
|
EncryptionMismatchScreen,
|
||||||
|
} from '@/features/encryption'
|
||||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||||
|
import { RiInformationLine } from '@remixicon/react'
|
||||||
import { openPermissionsDialog } from '@/stores/permissions'
|
import { openPermissionsDialog } from '@/stores/permissions'
|
||||||
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
|
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
|
||||||
import { isSafari } from '@/utils/livekit'
|
import { isSafari } from '@/utils/livekit'
|
||||||
@@ -103,6 +109,29 @@ export const Join = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: '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 {
|
const {
|
||||||
userChoices: {
|
userChoices: {
|
||||||
audioEnabled,
|
audioEnabled,
|
||||||
@@ -173,11 +202,6 @@ export const Join = ({
|
|||||||
[tracks]
|
[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(() => {
|
useEffect(() => {
|
||||||
const createVideoTrack = async () => {
|
const createVideoTrack = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -245,8 +269,6 @@ export const Join = ({
|
|||||||
const videoTrack = dynamicVideoTrack || previewVideoTrack
|
const videoTrack = dynamicVideoTrack || previewVideoTrack
|
||||||
const audioTrack = dynamicAudioTrack || previewAudioTrack
|
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(
|
useResolveInitiallyDefaultDeviceId(
|
||||||
audioDeviceId,
|
audioDeviceId,
|
||||||
audioTrack,
|
audioTrack,
|
||||||
@@ -286,12 +308,6 @@ export const Join = ({
|
|||||||
}
|
}
|
||||||
}, [videoTrack, videoEnabled])
|
}, [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 {
|
const {
|
||||||
data: roomData,
|
data: roomData,
|
||||||
error,
|
error,
|
||||||
@@ -333,7 +349,6 @@ export const Join = ({
|
|||||||
const { data } = await refetchRoom()
|
const { data } = await refetchRoom()
|
||||||
|
|
||||||
if (!data?.livekit) {
|
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) {
|
if (data?.access_level == ApiAccessLevel.TRUSTED) {
|
||||||
openLoginHint()
|
openLoginHint()
|
||||||
}
|
}
|
||||||
@@ -426,6 +441,12 @@ export const Join = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
if (unexpectedPassphrase) {
|
||||||
|
return <EncryptionMismatchScreen reason="unexpectedPassphrase" />
|
||||||
|
}
|
||||||
|
if (liveEncryption && !hasValidPassphrase) {
|
||||||
|
return <EncryptionMismatchScreen reason="missingPassphrase" />
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -452,6 +473,21 @@ export const Join = ({
|
|||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
maxLength={50}
|
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>
|
</VStack>
|
||||||
</Form>
|
</Form>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export const useLobby = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
|
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
|
||||||
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
const clearWaitingTimeout = useCallback(() => {
|
const clearWaitingTimeout = useCallback(() => {
|
||||||
if (waitingTimeoutRef.current) {
|
if (waitingTimeoutRef.current) {
|
||||||
clearTimeout(waitingTimeoutRef.current)
|
clearTimeout(waitingTimeoutRef.current)
|
||||||
@@ -40,10 +39,7 @@ export const useLobby = ({
|
|||||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||||
queryKey: [keys.requestEntry, roomId],
|
queryKey: [keys.requestEntry, roomId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await requestEntry({
|
const response = await requestEntry({ roomId, username })
|
||||||
roomId,
|
|
||||||
username,
|
|
||||||
})
|
|
||||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||||
clearWaitingTimeout()
|
clearWaitingTimeout()
|
||||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||||
@@ -60,7 +56,7 @@ export const useLobby = ({
|
|||||||
enabled: status === ApiLobbyStatus.WAITING,
|
enabled: status === ApiLobbyStatus.WAITING,
|
||||||
})
|
})
|
||||||
|
|
||||||
const startWaiting = useCallback(() => {
|
const startWaiting = useCallback(async () => {
|
||||||
setStatus(ApiLobbyStatus.WAITING)
|
setStatus(ApiLobbyStatus.WAITING)
|
||||||
startWaitingTimeout()
|
startWaitingTimeout()
|
||||||
}, [startWaitingTimeout])
|
}, [startWaitingTimeout])
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const useWaitingParticipants = () => {
|
|||||||
allowEntry: boolean
|
allowEntry: boolean
|
||||||
) => {
|
) => {
|
||||||
await enterRoom({
|
await enterRoom({
|
||||||
roomId: roomId,
|
roomId,
|
||||||
allowEntry,
|
allowEntry,
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
})
|
})
|
||||||
@@ -78,7 +78,7 @@ export const useWaitingParticipants = () => {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
waitingParticipants.map((participant) =>
|
waitingParticipants.map((participant) =>
|
||||||
enterRoom({
|
enterRoom({
|
||||||
roomId: roomId,
|
roomId,
|
||||||
allowEntry,
|
allowEntry,
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Div, Field, H, Text } from '@/primitives'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Separator as RACSeparator } from 'react-aria-components'
|
import { Separator as RACSeparator } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useRecordingStatuses } from '@/features/recording'
|
||||||
|
import { RecordingMode } from '@/features/recording'
|
||||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
@@ -38,6 +40,25 @@ export const Admin = () => {
|
|||||||
isScreenShareEnabled,
|
isScreenShareEnabled,
|
||||||
} = usePublishSourcesManager()
|
} = 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 (
|
return (
|
||||||
<Div
|
<Div
|
||||||
display="flex"
|
display="flex"
|
||||||
@@ -132,6 +153,122 @@ export const Admin = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export const AdminToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isAdminOpen}
|
isSelected={isAdminOpen}
|
||||||
aria-expanded={isAdminOpen}
|
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleAdmin()
|
toggleAdmin()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export const Info = () => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||||
|
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
const roomUrl = getRouteUrl('room', data?.slug)
|
const baseRoomUrl = getRouteUrl('room', data?.slug)
|
||||||
|
const roomUrl = window.location.hash
|
||||||
|
? `${baseRoomUrl}${window.location.hash}`
|
||||||
|
: baseRoomUrl
|
||||||
|
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
@@ -50,7 +53,14 @@ export const Info = () => {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Text as="p" variant="xsNote" wrap="pretty">
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="xsNote"
|
||||||
|
className={css({
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
})}
|
||||||
|
>
|
||||||
{roomUrl.replace(/^https?:\/\//, '')}
|
{roomUrl.replace(/^https?:\/\//, '')}
|
||||||
</Text>
|
</Text>
|
||||||
{isTelephonyReadyForUse && (
|
{isTelephonyReadyForUse && (
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
ConnectionQualityIndicator,
|
ConnectionQualityIndicator,
|
||||||
LockLockedIcon,
|
|
||||||
ParticipantTileProps,
|
ParticipantTileProps,
|
||||||
ScreenShareIcon,
|
ScreenShareIcon,
|
||||||
useEnsureTrackRef,
|
useEnsureTrackRef,
|
||||||
useFeatureContext,
|
useFeatureContext,
|
||||||
useIsEncrypted,
|
|
||||||
useMaybeLayoutContext,
|
useMaybeLayoutContext,
|
||||||
useMaybeTrackRefContext,
|
useMaybeTrackRefContext,
|
||||||
useParticipantTile,
|
useParticipantTile,
|
||||||
@@ -24,6 +22,12 @@ import { Track } from 'livekit-client'
|
|||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import {
|
||||||
|
IdentityBadge,
|
||||||
|
SipBlockedTileOverlay,
|
||||||
|
DecryptionFailedTileOverlay,
|
||||||
|
} from '@/features/encryption'
|
||||||
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||||
@@ -78,7 +82,6 @@ export const ParticipantTile: (
|
|||||||
onParticipantClick,
|
onParticipantClick,
|
||||||
trackRef: trackReference,
|
trackRef: trackReference,
|
||||||
})
|
})
|
||||||
const isEncrypted = useIsEncrypted(trackReference.participant)
|
|
||||||
const layoutContext = useMaybeLayoutContext()
|
const layoutContext = useMaybeLayoutContext()
|
||||||
|
|
||||||
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
const autoManageSubscription = useFeatureContext()?.autoSubscription
|
||||||
@@ -108,13 +111,19 @@ export const ParticipantTile: (
|
|||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
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 participantName = getParticipantName(trackReference.participant)
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
...elementProps,
|
...elementProps,
|
||||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
'aria-label': t('containerLabel', { name: participantName }),
|
'aria-label': t('containerLabel', { name: participantName }),
|
||||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
@@ -215,15 +224,18 @@ export const ParticipantTile: (
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isEncrypted && !isScreenShare && (
|
|
||||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
|
||||||
)}
|
|
||||||
<div className="lk-participant-name-wrapper">
|
<div className="lk-participant-name-wrapper">
|
||||||
<ParticipantName
|
<ParticipantName
|
||||||
isScreenShare={isScreenShare}
|
isScreenShare={isScreenShare}
|
||||||
participant={trackReference.participant}
|
participant={trackReference.participant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{showIdentityBadge && (
|
||||||
|
<IdentityBadge
|
||||||
|
participant={trackReference.participant}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||||
@@ -237,6 +249,16 @@ export const ParticipantTile: (
|
|||||||
hasKeyboardFocus={hasKeyboardFocus}
|
hasKeyboardFocus={hasKeyboardFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isScreenShare && (
|
||||||
|
<>
|
||||||
|
<SipBlockedTileOverlay
|
||||||
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
<DecryptionFailedTileOverlay
|
||||||
|
participant={trackReference.participant}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
</TrackRefContextIfNeeded>
|
</TrackRefContextIfNeeded>
|
||||||
<KeyboardShortcutHint>
|
<KeyboardShortcutHint>
|
||||||
|
|||||||
+53
-20
@@ -2,17 +2,19 @@ import { createPortal } from 'react-dom'
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { Text } from '@/primitives'
|
import { Text } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||||
|
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||||
|
import { accessibilityStore } from '@/stores/accessibility'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { reactionsStore } from '@/stores/reactions'
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
import { useAnnounceReaction } from '../hooks/useAnnounceReaction'
|
|
||||||
import { Reaction } from '../types'
|
export const ANIMATION_DURATION = 3000
|
||||||
import {
|
export const ANIMATION_DISTANCE = 300
|
||||||
ANIMATION_DISTANCE,
|
export const FADE_OUT_THRESHOLD = 0.7
|
||||||
ANIMATION_DURATION,
|
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
|
||||||
FADE_OUT_THRESHOLD,
|
export const INITIAL_POSITION = 200
|
||||||
INITIAL_POSITION,
|
|
||||||
REACTION_SPAWN_WIDTH_RATIO,
|
|
||||||
} from '../constants'
|
|
||||||
|
|
||||||
interface FloatingReactionProps {
|
interface FloatingReactionProps {
|
||||||
emoji: string
|
emoji: string
|
||||||
@@ -77,7 +79,7 @@ export function FloatingReaction({
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/assets/reactions/${emoji}.png`}
|
src={`/assets/reactions/${emoji}.png`}
|
||||||
alt=""
|
alt={''}
|
||||||
className={css({
|
className={css({
|
||||||
height: '50px',
|
height: '50px',
|
||||||
})}
|
})}
|
||||||
@@ -114,7 +116,14 @@ export function FloatingReaction({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReactionPortal = ({ reaction }: { reaction: Reaction }) => {
|
export function ReactionPortal({
|
||||||
|
emoji,
|
||||||
|
participant,
|
||||||
|
}: {
|
||||||
|
emoji: string
|
||||||
|
participant: Participant
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
|
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
|
||||||
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -129,27 +138,51 @@ const ReactionPortal = ({ reaction }: { reaction: Reaction }) => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FloatingReaction
|
<FloatingReaction
|
||||||
emoji={reaction.emoji}
|
emoji={emoji}
|
||||||
speed={speed}
|
speed={speed}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
name={reaction.participantName}
|
name={participant?.isLocal ? t('you') : participant.name}
|
||||||
isLocal={reaction.isLocal}
|
isLocal={participant?.isLocal}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReactionPortals = () => {
|
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
||||||
const { reactions } = useSnapshot(reactionsStore)
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
const latestReaction = reactions.at(-1)
|
const { announceReactions } = useSnapshot(accessibilityStore)
|
||||||
|
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
useAnnounceReaction(latestReaction)
|
const latestReaction =
|
||||||
|
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!announceReactions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!latestReaction) return
|
||||||
|
const isNewReaction = latestReaction.id !== lastAnnouncedId
|
||||||
|
if (!isNewReaction) return
|
||||||
|
|
||||||
|
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
|
||||||
|
const participantName = latestReaction.participant?.isLocal
|
||||||
|
? t('you')
|
||||||
|
: latestReaction.participant?.name?.trim() ||
|
||||||
|
t('someone', { defaultValue: 'Someone' })
|
||||||
|
announce(t('announce', { name: participantName, emoji: emojiLabel }))
|
||||||
|
setLastAnnouncedId(latestReaction.id)
|
||||||
|
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{reactions.map((instance) => (
|
{reactions.map((instance) => (
|
||||||
<ReactionPortal key={instance.id} reaction={instance} />
|
<ReactionPortal
|
||||||
|
key={instance.id}
|
||||||
|
emoji={instance.emoji}
|
||||||
|
participant={instance.participant}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -14,7 +14,6 @@ import { Admin } from './Admin'
|
|||||||
import { Tools } from './Tools'
|
import { Tools } from './Tools'
|
||||||
import { Info } from './Info'
|
import { Info } from './Info'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -26,7 +25,6 @@ type StyledSidePanelProps = {
|
|||||||
isSubmenu: boolean
|
isSubmenu: boolean
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
backButtonLabel: string
|
backButtonLabel: string
|
||||||
isReactionToolbarOpen?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledSidePanel = ({
|
const StyledSidePanel = ({
|
||||||
@@ -35,7 +33,6 @@ const StyledSidePanel = ({
|
|||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
isClosed,
|
isClosed,
|
||||||
isReactionToolbarOpen,
|
|
||||||
closeButtonTooltip,
|
closeButtonTooltip,
|
||||||
isSubmenu = false,
|
isSubmenu = false,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -54,23 +51,17 @@ const StyledSidePanel = ({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
margin: 'var(--sizes-room-side-panel-margin)',
|
margin: '1.5rem 1.5rem 1.5rem 0',
|
||||||
marginLeft: 0,
|
|
||||||
marginBottom: 0,
|
|
||||||
padding: 0,
|
padding: 0,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
width: 'var(--sizes-room-side-panel)',
|
bottom: '80px',
|
||||||
|
width: '360px',
|
||||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||||
})}
|
})}
|
||||||
style={{
|
style={{
|
||||||
transform: isClosed
|
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||||
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
|
||||||
: 'none',
|
|
||||||
bottom: isReactionToolbarOpen
|
|
||||||
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
|
||||||
: 'var(--sizes-room-control-bar)',
|
|
||||||
}}
|
}}
|
||||||
aria-hidden={isClosed}
|
aria-hidden={isClosed}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
@@ -160,8 +151,6 @@ export const SidePanel = () => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||||
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||||
|
|
||||||
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={title}
|
title={title}
|
||||||
@@ -175,7 +164,6 @@ export const SidePanel = () => {
|
|||||||
})}
|
})}
|
||||||
isClosed={!isSidePanelOpen}
|
isClosed={!isSidePanelOpen}
|
||||||
isSubmenu={isSubPanelOpen}
|
isSubmenu={isSubPanelOpen}
|
||||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
|
||||||
backButtonLabel={t('backToTools')}
|
backButtonLabel={t('backToTools')}
|
||||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { A, Div, Icon, Text } from '@/primitives'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode, useState } from 'react'
|
||||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||||
import {
|
import {
|
||||||
@@ -12,12 +12,19 @@ import {
|
|||||||
ScreenRecordingSidePanel,
|
ScreenRecordingSidePanel,
|
||||||
} from '@/features/recording'
|
} from '@/features/recording'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import {
|
||||||
|
EncryptionPhase,
|
||||||
|
PauseEncryptionConfirmDialog,
|
||||||
|
useEncryptionStatus,
|
||||||
|
} from '@/features/encryption'
|
||||||
|
|
||||||
|
|
||||||
export interface ToolsButtonProps {
|
export interface ToolsButtonProps {
|
||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
|
isDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolButton = ({
|
const ToolButton = ({
|
||||||
@@ -25,9 +32,11 @@ const ToolButton = ({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
onPress,
|
onPress,
|
||||||
|
isDisabled,
|
||||||
}: ToolsButtonProps) => {
|
}: ToolsButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<RACButton
|
<RACButton
|
||||||
|
isDisabled={isDisabled}
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -39,10 +48,14 @@ const ToolButton = ({
|
|||||||
width: 'full',
|
width: 'full',
|
||||||
backgroundColor: 'gray.50',
|
backgroundColor: 'gray.50',
|
||||||
textAlign: 'start',
|
textAlign: 'start',
|
||||||
'&[data-hovered]': {
|
'&[data-hovered]:not([data-disabled])': {
|
||||||
backgroundColor: 'primary.50',
|
backgroundColor: 'primary.50',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
|
'&[data-disabled]': {
|
||||||
|
opacity: 0.5,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
>
|
>
|
||||||
@@ -98,17 +111,17 @@ export const Tools = () => {
|
|||||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||||
useSidePanel()
|
useSidePanel()
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
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
|
// Restore focus to the element that opened the Tools panel
|
||||||
// following the same pattern as Chat.
|
|
||||||
useRestoreFocus(isToolsOpen, {
|
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) => {
|
resolveTrigger: (activeEl) => {
|
||||||
if (activeEl?.tagName === 'DIV') {
|
if (activeEl?.tagName === 'DIV') {
|
||||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||||
}
|
}
|
||||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
|
||||||
return activeEl
|
return activeEl
|
||||||
},
|
},
|
||||||
restoreFocusRaf: true,
|
restoreFocusRaf: true,
|
||||||
@@ -132,6 +145,15 @@ export const Tools = () => {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePress = (reason: 'recording' | 'transcript') => {
|
||||||
|
if (phase === EncryptionPhase.ENCRYPTED) {
|
||||||
|
setConfirmReason(reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (reason === 'recording') openScreenRecording()
|
||||||
|
else openTranscript()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Div
|
<Div
|
||||||
display="flex"
|
display="flex"
|
||||||
@@ -171,7 +193,7 @@ export const Tools = () => {
|
|||||||
icon={<Icon type="symbols" name="speech_to_text" />}
|
icon={<Icon type="symbols" name="speech_to_text" />}
|
||||||
title={t('tools.transcript.title')}
|
title={t('tools.transcript.title')}
|
||||||
description={t('tools.transcript.body')}
|
description={t('tools.transcript.body')}
|
||||||
onPress={() => openTranscript()}
|
onPress={() => handlePress('transcript')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isScreenRecordingEnabled && (
|
{isScreenRecordingEnabled && (
|
||||||
@@ -179,9 +201,22 @@ export const Tools = () => {
|
|||||||
icon={<Icon type="symbols" name="mode_standby" />}
|
icon={<Icon type="symbols" name="mode_standby" />}
|
||||||
title={t('tools.screenRecording.title')}
|
title={t('tools.screenRecording.title')}
|
||||||
description={t('tools.screenRecording.body')}
|
description={t('tools.screenRecording.body')}
|
||||||
onPress={() => openScreenRecording()}
|
onPress={() => handlePress('recording')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<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>
|
</Div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export const ChatToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isChatOpen}
|
isSelected={isChatOpen}
|
||||||
aria-expanded={isChatOpen}
|
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleChat()
|
toggleChat()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export const InfoToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isInfoOpen}
|
isSelected={isInfoOpen}
|
||||||
aria-expanded={isInfoOpen}
|
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleInfo()
|
toggleInfo()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
+35
-9
@@ -1,15 +1,23 @@
|
|||||||
import { RiRecordCircleLine } from '@remixicon/react'
|
import { RiRecordCircleLine } from '@remixicon/react'
|
||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
||||||
import { FeatureFlags } from '@/features/analytics/enums'
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
|
import {
|
||||||
|
EncryptionPhase,
|
||||||
|
PauseEncryptionConfirmDialog,
|
||||||
|
useEncryptionStatus,
|
||||||
|
} from '@/features/encryption'
|
||||||
|
|
||||||
export const ScreenRecordingMenuItem = () => {
|
export const ScreenRecordingMenuItem = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
|
const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
|
||||||
useSidePanel()
|
useSidePanel()
|
||||||
|
const { phase, pauseEncryption } = useEncryptionStatus()
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
|
||||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||||
RecordingMode.ScreenRecording,
|
RecordingMode.ScreenRecording,
|
||||||
@@ -18,15 +26,33 @@ export const ScreenRecordingMenuItem = () => {
|
|||||||
|
|
||||||
if (!hasScreenRecordingAccess) return null
|
if (!hasScreenRecordingAccess) return null
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (phase === EncryptionPhase.ENCRYPTED) {
|
||||||
|
setConfirmOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isScreenRecordingOpen) openScreenRecording()
|
||||||
|
else toggleTools()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<>
|
||||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
<MenuItem
|
||||||
onAction={() =>
|
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||||
!isScreenRecordingOpen ? openScreenRecording() : toggleTools()
|
onAction={handlePress}
|
||||||
}
|
>
|
||||||
>
|
<RiRecordCircleLine size={20} />
|
||||||
<RiRecordCircleLine size={20} />
|
{t('screenRecording')}
|
||||||
{t('screenRecording')}
|
</MenuItem>
|
||||||
</MenuItem>
|
<PauseEncryptionConfirmDialog
|
||||||
|
isOpen={confirmOpen}
|
||||||
|
onOpenChange={setConfirmOpen}
|
||||||
|
reason="recording"
|
||||||
|
onConfirm={async () => {
|
||||||
|
const ok = await pauseEncryption('recording')
|
||||||
|
if (ok) openScreenRecording()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-7
@@ -1,14 +1,22 @@
|
|||||||
import { RiFileTextLine } from '@remixicon/react'
|
import { RiFileTextLine } from '@remixicon/react'
|
||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
|
||||||
import { FeatureFlags } from '@/features/analytics/enums'
|
import { FeatureFlags } from '@/features/analytics/enums'
|
||||||
|
import {
|
||||||
|
EncryptionPhase,
|
||||||
|
PauseEncryptionConfirmDialog,
|
||||||
|
useEncryptionStatus,
|
||||||
|
} from '@/features/encryption'
|
||||||
|
|
||||||
export const TranscriptMenuItem = () => {
|
export const TranscriptMenuItem = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
|
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
|
||||||
|
const { phase, pauseEncryption } = useEncryptionStatus()
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
|
||||||
const hasTranscriptAccess = useHasRecordingAccess(
|
const hasTranscriptAccess = useHasRecordingAccess(
|
||||||
RecordingMode.Transcript,
|
RecordingMode.Transcript,
|
||||||
@@ -17,13 +25,33 @@ export const TranscriptMenuItem = () => {
|
|||||||
|
|
||||||
if (!hasTranscriptAccess) return null
|
if (!hasTranscriptAccess) return null
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
if (phase === EncryptionPhase.ENCRYPTED) {
|
||||||
|
setConfirmOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isTranscriptOpen) openTranscript()
|
||||||
|
else toggleTools()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<>
|
||||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
<MenuItem
|
||||||
onAction={() => (!isTranscriptOpen ? openTranscript() : toggleTools())}
|
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||||
>
|
onAction={handlePress}
|
||||||
<RiFileTextLine size={20} />
|
>
|
||||||
{t('transcript')}
|
<RiFileTextLine size={20} />
|
||||||
</MenuItem>
|
{t('transcript')}
|
||||||
|
</MenuItem>
|
||||||
|
<PauseEncryptionConfirmDialog
|
||||||
|
isOpen={confirmOpen}
|
||||||
|
onOpenChange={setConfirmOpen}
|
||||||
|
reason="transcript"
|
||||||
|
onConfirm={async () => {
|
||||||
|
const ok = await pauseEncryption('transcript')
|
||||||
|
if (ok) openTranscript()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-23
@@ -21,6 +21,7 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
import { ParticipantMenuButton } from '../../ParticipantMenu/ParticipantMenuButton'
|
||||||
import { PinBadge } from './PinBadge'
|
import { PinBadge } from './PinBadge'
|
||||||
|
import { IdentityBadge, useEncryptionStatus, EncryptionPhase } from '@/features/encryption'
|
||||||
|
|
||||||
type MicIndicatorProps = {
|
type MicIndicatorProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
@@ -97,6 +98,8 @@ export const ParticipantListItem = ({
|
|||||||
participant,
|
participant,
|
||||||
}: ParticipantListItemProps) => {
|
}: ParticipantListItemProps) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const { phase } = useEncryptionStatus()
|
||||||
|
const showIdentityBadge = phase !== EncryptionPhase.UNENCRYPTED
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
@@ -122,35 +125,21 @@ export const ParticipantListItem = ({
|
|||||||
variant="sm"
|
variant="sm"
|
||||||
className={css({
|
className={css({
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
cursor: 'default',
|
whiteSpace: 'nowrap',
|
||||||
display: 'flex',
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '150px',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span
|
{name}
|
||||||
className={css({
|
{isLocal(participant) && ` (${t('participants.you')})`}
|
||||||
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>
|
</Text>
|
||||||
{getParticipantIsRoomAdmin(participant) && (
|
{getParticipantIsRoomAdmin(participant) && (
|
||||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||||
)}
|
)}
|
||||||
|
{showIdentityBadge && (
|
||||||
|
<IdentityBadge participant={participant} size="sm" />
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack>
|
<HStack>
|
||||||
|
|||||||
-2
@@ -48,7 +48,6 @@ export const ParticipantsToggle = ({
|
|||||||
count: announcedCount,
|
count: announcedCount,
|
||||||
})}.`}
|
})}.`}
|
||||||
isSelected={isParticipantsOpen}
|
isSelected={isParticipantsOpen}
|
||||||
aria-expanded={isParticipantsOpen}
|
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleParticipants()
|
toggleParticipants()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
@@ -77,7 +76,6 @@ export const ParticipantsToggle = ({
|
|||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
})}
|
})}
|
||||||
aria-hidden={true}
|
|
||||||
>
|
>
|
||||||
{numParticipants < 100 ? (
|
{numParticipants < 100 ? (
|
||||||
numParticipants || 1
|
numParticipants || 1
|
||||||
|
|||||||
+17
-19
@@ -1,10 +1,12 @@
|
|||||||
import { Button, Text } from '@/primitives'
|
import { Button, Text } from '@/primitives'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack, VStack } from '@/styled-system/jsx'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
import { IdentityBadge } from '@/features/encryption'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
|
||||||
export const WaitingParticipantListItem = ({
|
export const WaitingParticipantListItem = ({
|
||||||
participant,
|
participant,
|
||||||
@@ -14,6 +16,8 @@ export const WaitingParticipantListItem = ({
|
|||||||
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const roomData = useRoomData()
|
||||||
|
const showIdentityBadge = !!roomData?.is_encrypted
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
@@ -30,37 +34,31 @@ export const WaitingParticipantListItem = ({
|
|||||||
className={css({
|
className={css({
|
||||||
flex: '1',
|
flex: '1',
|
||||||
minWidth: '0',
|
minWidth: '0',
|
||||||
|
gap: '0.35rem',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Avatar name={participant.username} bgColor={participant.color} />
|
<Avatar name={participant.username} bgColor={participant.color} />
|
||||||
<Text
|
<VStack gap={0} alignItems="start" className={css({ flex: 1, minWidth: 0 })}>
|
||||||
variant={'sm'}
|
<Text
|
||||||
className={css({
|
variant="sm"
|
||||||
userSelect: 'none',
|
|
||||||
cursor: 'default',
|
|
||||||
display: 'flex',
|
|
||||||
flex: '1',
|
|
||||||
minWidth: '0',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={css({
|
className={css({
|
||||||
|
userSelect: 'none',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
width: '100%',
|
padding: '0.1rem 0.25rem',
|
||||||
display: 'block',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{participant.username}
|
{participant.username}
|
||||||
</span>
|
</Text>
|
||||||
</Text>
|
{showIdentityBadge && (
|
||||||
|
<IdentityBadge isAuthenticated={participant.is_authenticated} />
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack
|
<HStack
|
||||||
gap="0.25rem"
|
gap="0.25rem"
|
||||||
className={css({
|
className={css({ flexShrink: '0' })}
|
||||||
flexShrink: '0',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiEmotionLine } from '@remixicon/react'
|
||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { ToggleButton, Button } from '@/primitives'
|
||||||
|
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||||
|
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||||
|
import {
|
||||||
|
ANIMATION_DURATION,
|
||||||
|
ReactionPortals,
|
||||||
|
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||||
|
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import {
|
||||||
|
Popover as RACPopover,
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
import { FocusScope } from '@react-aria/focus'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export enum Emoji {
|
||||||
|
THUMBS_UP = 'thumbs-up',
|
||||||
|
THUMBS_DOWN = 'thumbs-down',
|
||||||
|
CLAP = 'clapping-hands',
|
||||||
|
HEART = 'red-heart',
|
||||||
|
LAUGHING = 'face-with-tears-of-joy',
|
||||||
|
SURPRISED = 'face-with-open-mouth',
|
||||||
|
CELEBRATION = 'party-popper',
|
||||||
|
PLEASE = 'folded-hands',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Reaction {
|
||||||
|
id: number
|
||||||
|
emoji: string
|
||||||
|
participant: Participant
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReactionsToggle = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
|
const [reactions, setReactions] = useState<Reaction[]>([])
|
||||||
|
const instanceIdRef = useRef(0)
|
||||||
|
const room = useRoomContext()
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'reaction',
|
||||||
|
handler: () => setIsOpen((prev) => !prev),
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendReaction = async (emoji: string) => {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const payload: NotificationPayload = {
|
||||||
|
type: NotificationType.ReactionReceived,
|
||||||
|
data: {
|
||||||
|
emoji: emoji,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const data = encoder.encode(JSON.stringify(payload))
|
||||||
|
await room.localParticipant.publishData(data, { reliable: true })
|
||||||
|
|
||||||
|
const newReaction = {
|
||||||
|
id: instanceIdRef.current++,
|
||||||
|
emoji,
|
||||||
|
participant: room.localParticipant,
|
||||||
|
}
|
||||||
|
setReactions((prev) => [...prev, newReaction])
|
||||||
|
|
||||||
|
// Remove this reaction after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
setReactions((prev) =>
|
||||||
|
prev.filter((instance) => instance.id !== newReaction.id)
|
||||||
|
)
|
||||||
|
}, ANIMATION_DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSendReaction = useRateLimiter({
|
||||||
|
callback: sendReaction,
|
||||||
|
maxCalls: 10,
|
||||||
|
windowMs: 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={css({ position: 'relative' })}>
|
||||||
|
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<ToggleButton
|
||||||
|
square
|
||||||
|
variant="primaryDark"
|
||||||
|
aria-label={t('button')}
|
||||||
|
tooltip={t('button')}
|
||||||
|
isSelected={isOpen}
|
||||||
|
onChange={setIsOpen}
|
||||||
|
>
|
||||||
|
<RiEmotionLine />
|
||||||
|
</ToggleButton>
|
||||||
|
<RACPopover
|
||||||
|
placement="top"
|
||||||
|
offset={8}
|
||||||
|
isNonModal
|
||||||
|
shouldCloseOnInteractOutside={() => false}
|
||||||
|
className={css({
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '0.35rem',
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
'&[data-entering]': {
|
||||||
|
animation: 'fade 200ms ease',
|
||||||
|
},
|
||||||
|
'&[data-exiting]': {
|
||||||
|
animation: 'fade 200ms ease-in reverse',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Dialog className={css({ outline: 'none' })}>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
|
||||||
|
<FocusScope contain autoFocus restoreFocus>
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label={t('button')}
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{Object.values(Emoji).map((emoji, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
onPress={() => debouncedSendReaction(emoji)}
|
||||||
|
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||||
|
variant="primaryTextDark"
|
||||||
|
size="sm"
|
||||||
|
square
|
||||||
|
data-attr={`send-reaction-${emoji}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/assets/reactions/${emoji}.png`}
|
||||||
|
alt=""
|
||||||
|
className={css({
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FocusScope>
|
||||||
|
</Dialog>
|
||||||
|
</RACPopover>
|
||||||
|
</DialogTrigger>
|
||||||
|
</div>
|
||||||
|
<ReactionPortals reactions={reactions} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,7 +34,6 @@ export const ToolsToggle = ({
|
|||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isToolsOpen}
|
isSelected={isToolsOpen}
|
||||||
aria-expanded={isToolsOpen}
|
|
||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
toggleTools()
|
toggleTools()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
|
|||||||
+3
-3
@@ -7,9 +7,9 @@ import {
|
|||||||
useSwipe,
|
useSwipe,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { mergeProps } from '@/utils/mergeProps'
|
import { mergeProps } from '@/utils/mergeProps'
|
||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from '../controls/PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from '../controls/PaginationControl'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
@@ -7,7 +7,7 @@ import { getRouteUrl } from '@/navigation/getRouteUrl'
|
|||||||
|
|
||||||
const COPY_SUCCESS_TIMEOUT = 3000
|
const COPY_SUCCESS_TIMEOUT = 3000
|
||||||
|
|
||||||
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
export const useCopyRoomToClipboard = (room: ApiRoom | undefined, hashOverride?: string) => {
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
|
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
|
||||||
|
|
||||||
@@ -32,8 +32,12 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
|||||||
}, [isRoomUrlCopied])
|
}, [isRoomUrlCopied])
|
||||||
|
|
||||||
const roomUrl = useMemo(() => {
|
const roomUrl = useMemo(() => {
|
||||||
return room?.slug ? getRouteUrl('room', room.slug) : ''
|
if (!room?.slug) return ''
|
||||||
}, [room?.slug])
|
const base = getRouteUrl('room', room.slug)
|
||||||
|
// In basic encrypted mode, the passphrase is in the URL hash
|
||||||
|
const hash = hashOverride ? `#${hashOverride}` : window.location.hash
|
||||||
|
return hash ? `${base}${hash}` : base
|
||||||
|
}, [room?.slug, hashOverride])
|
||||||
|
|
||||||
const hasTelephonyInfo = useMemo(() => {
|
const hasTelephonyInfo = useMemo(() => {
|
||||||
return telephony.enabled && room?.pin_code
|
return telephony.enabled && room?.pin_code
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user