Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12e99f8f2a | |||
| 3358b23edd | |||
| 558d844ef3 | |||
| 94aa828d87 | |||
| 110e062e56 | |||
| f7c2beb30d | |||
| 5bf1456200 | |||
| 80e5c9bb95 | |||
| 74164b8498 | |||
| 82cc2e5d17 | |||
| fa1503db0d | |||
| e106415740 | |||
| 4199328a4a | |||
| 57715fea73 | |||
| c969c302bc | |||
| be29e193c5 | |||
| 53d0e9fe12 | |||
| e04d9a9dab | |||
| fdb6dda65b | |||
| 332662d1e5 | |||
| a8e1bbe085 | |||
| 9eae98ed16 | |||
| 3cae3e66c4 | |||
| 7e463f4554 | |||
| 1c40003c3c | |||
| 5f07d4a88b | |||
| 6cf8e23ab2 | |||
| b4016ce850 | |||
| 74aba2185a | |||
| 0bbaae7c5e | |||
| fbee41f5dd | |||
| 2503411311 | |||
| 13944ceebd | |||
| 50719e8c25 | |||
| 11c2c2dea8 | |||
| d2f79d4524 | |||
| 2168643fd4 | |||
| 356797d326 | |||
| 19b88a2078 | |||
| b169e57193 | |||
| 5d81ba1e20 | |||
| b248395cd6 | |||
| 3eef4765df | |||
| ae920c0c9b | |||
| 86cb10a3c7 | |||
| 634b34f2e9 | |||
| 33774a44d4 | |||
| ce1c3d26d2 | |||
| b5e7d7eeec | |||
| 50b9509c2c | |||
| aaf1163910 | |||
| 00cd4fc92a | |||
| 0aa4f6389b | |||
| e2f60775a9 | |||
| e20acfa5a9 | |||
| fac9435bc7 | |||
| 3e9992bae3 | |||
| 25d4ede2dd | |||
| 6545ecf11a | |||
| b73f18419b | |||
| 49163eba67 | |||
| 38c3776556 | |||
| 75e4092dad | |||
| 2774d76176 | |||
| da05438f45 | |||
| 5d89efec78 | |||
| 7c46029f87 | |||
| e535040ac6 | |||
| 92851b10cc | |||
| ea37a3154e | |||
| 65ddf2e2a1 | |||
| a48501bc02 | |||
| 4d961ed162 | |||
| 710d7964ee | |||
| 01f4d05d6b | |||
| 7fad60d9a9 | |||
| e9ebac46ac | |||
| d64e5d1923 | |||
| c9512004ac | |||
| 879cf20891 | |||
| 433a3a7cdf | |||
| c0bcced3c0 | |||
| 4045662433 | |||
| e7f11194ce | |||
| 4955f3eea7 | |||
| c576a75660 | |||
| 51fa4e84e4 | |||
| eec6a46883 | |||
| 19a240c63f | |||
| 2236674849 | |||
| e5fe81de4d | |||
| 38ab001bcf | |||
| 14885e0ffa | |||
| 054e7ba945 | |||
| 25506b6a2a | |||
| 0498a9e7f9 | |||
| 0a63270154 | |||
| b962dddbf2 | |||
| 16929bcc83 | |||
| 354bdd8bfe | |||
| ccca2b9472 | |||
| 9fc5681137 | |||
| edf29076ba | |||
| 337181add8 | |||
| 5af64e539d | |||
| 291544bd52 | |||
| 13bb6acdf6 | |||
| aa2783ae2e | |||
| c3e4ea0fd1 | |||
| 183e8f6a72 | |||
| 452dbe8bba | |||
| 8818d12ee9 | |||
| fb0c9b766d | |||
| ba873196c7 | |||
| 48937bb7a3 | |||
| 6f09eb3602 | |||
| b7fb6b1b69 | |||
| 2cd4a6efa8 | |||
| b5037db685 | |||
| 19804d2e3f | |||
| a979f05549 | |||
| 09a0496d25 | |||
| da95e804a0 | |||
| 591a3a5d8b | |||
| 30ed0da416 | |||
| bd4fcc2a5e | |||
| fc36ae8b49 | |||
| 87baca247b | |||
| 42d3846660 | |||
| 2d393f9f70 | |||
| b4b4ff79d9 | |||
| 861244ce01 | |||
| 6373593de3 | |||
| 03f6e6519b | |||
| 716d40dd4e | |||
| 17f8ec6319 | |||
| 0b74cf96f2 | |||
| 723b8718f9 | |||
| 07a44dab36 | |||
| e47b027bbc | |||
| d7a4f3946c | |||
| 1b7523bbf1 | |||
| 4326df4b6a |
@@ -117,8 +117,7 @@ jobs:
|
||||
- build-and-push-backend
|
||||
- build-and-push-summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
@@ -126,6 +125,6 @@ jobs:
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
@@ -110,6 +110,16 @@ jobs:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- 6379:6379
|
||||
# Set health checks to wait until redis has started
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
DJANGO_CONFIGURATION: Test
|
||||
@@ -121,6 +131,7 @@ jobs:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
REDIS_URL: redis://localhost:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
@@ -144,7 +155,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
@@ -175,25 +186,56 @@ jobs:
|
||||
- name: Check format
|
||||
run: cd src/frontend/ && npm run check
|
||||
|
||||
lint-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/sdk/library
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Check format
|
||||
run: npm run check
|
||||
|
||||
build-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-sdk
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/sdk/library
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build SDK
|
||||
run: npm run build
|
||||
|
||||
i18n-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "infrastructure,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
- name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
@@ -207,7 +249,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install development dependencies
|
||||
working-directory: src/backend
|
||||
|
||||
@@ -3,10 +3,8 @@ run-name: Release Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- ./src/helm/meet/**
|
||||
- src/helm/meet/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
||||
@@ -79,3 +79,6 @@ db.sqlite3
|
||||
|
||||
# Egress output
|
||||
docker/livekit/out
|
||||
|
||||
# LiveKit CA configuration
|
||||
docker/livekit/rootCA.pem
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at visio@numerique.gouv.fr. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [PostHog Code of Conduct](https://github.com/PostHog/posthog/blob/master/CODE_OF_CONDUCT.md), inspired from Contributor Covenant version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
@@ -0,0 +1,77 @@
|
||||
# Contributing to the Project
|
||||
|
||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/meet/blob/main/README.md) for detailed instructions.
|
||||
|
||||
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
||||
|
||||
|
||||
## Creating an Issue
|
||||
|
||||
When creating an issue, please provide the following details:
|
||||
|
||||
1. **Title**: A concise and descriptive title for the issue.
|
||||
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
|
||||
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
|
||||
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
|
||||
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
|
||||
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
All commit messages must adhere to the following format:
|
||||
|
||||
`<gitmoji>(type) title description`
|
||||
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change, starting with a lowercase character.
|
||||
* **description**: Include additional details about what was changed and why.
|
||||
|
||||
### Example Commit Message
|
||||
|
||||
```
|
||||
✨(frontend) add user authentication logic
|
||||
|
||||
Implemented login and signup features, and integrated OAuth2 for social login.
|
||||
```
|
||||
|
||||
## Changelog Update
|
||||
|
||||
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
|
||||
|
||||
### Example Changelog Message
|
||||
```
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(frontend) add AI to the project #321
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||
|
||||
### Don't forget to:
|
||||
- check your commits
|
||||
- check the linting: `make lint && make frontend-lint`
|
||||
- check the tests: `make test`
|
||||
- add a changelog entry
|
||||
|
||||
Once all the required tests have passed, you can request a review from the project maintainers.
|
||||
|
||||
## Code Style
|
||||
|
||||
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
|
||||
|
||||
## Tests
|
||||
|
||||
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
|
||||
|
||||
## Asking for Help
|
||||
|
||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||
|
||||
Thank you for your contributions! 👍
|
||||
@@ -0,0 +1,79 @@
|
||||
# LICENCE OUVERTE 2.0/OPEN LICENCE 2.0
|
||||
|
||||
## Réutilisation de l’« Information » sous cette licence
|
||||
|
||||
Le « Concédant » concède au « Réutilisateur » un droit non exclusif et gratuit de libre « Réutilisation » de l’« Information » objet de la présente licence, à des fins commerciales ou non, dans le monde entier et pour une durée illimitée, dans les conditions exprimées ci-dessous.
|
||||
|
||||
**Le « Réutilisateur » est libre de réutiliser l’« Information » :**
|
||||
|
||||
- de la communiquer, la reproduire, la copier ;
|
||||
- de l’adapter, la modifier, l’extraire et la transformer, notamment pour créer des « Informations dérivées » ;
|
||||
- de la diffuser, la redistribuer, la publier et la transmettre, de l’exploiter à titre commercial, par exemple en la combinant avec d’autres informations, ou en l’incluant dans votre propre produit ou application.
|
||||
|
||||
**Sous réserve de :**
|
||||
|
||||
- mentionner la paternité de l’«Information» : sa source (a minima le nom du « Concédant ») et la date de la dernière mise à jour de l’« Information » réutilisée.
|
||||
|
||||
Le « Réutilisateur » peut notamment s’ acquitter de cette condition en indiquant l’adresse (URL) renvoyant vers « l’Information » et assurant une mention effective de sa paternité.
|
||||
|
||||
**Par exemple :**
|
||||
|
||||
Dans le cas d’une réutilisation de la base SIRENE de l’INSEE, mentionner l’URL du « Concédant » : www.insee.fr + la date de dernière mise à jour de l’Information réutilisée.
|
||||
|
||||
Cette mention de paternité ne doit ni conférer un caractère officiel à la « Réutilisation » de l’« Information », ni suggérer une quelconque reconnaissance ou caution par le « Concédant », ou par toute autre entité publique, du « Réutilisateur » ou de sa « Réutilisation ».
|
||||
|
||||
## Données à caractère personnel
|
||||
|
||||
L’« Information » mise à disposition peut contenir des « Données à caractère personnel » pouvant faire l’objet d’une « Réutilisation ». Alors, le « Concédant » informe le « Réutilisateur » (par tous moyens) de leur présence, l’ « Information » peut être librement réutilisée, sans faire obstacle aux libertés accordées par la présente licence, à condition de respecter le cadre légal relatif à la protection des données à caractère personnel.
|
||||
|
||||
## Droits de propriété intellectuelle
|
||||
|
||||
Il est garanti au « Réutilisateur » que l’ « Information » ne contient pas de « Droits de propriété intellectuelle » appartenant à des tiers qui pourraient faire obstacle aux libertés qui lui sont accordées par la présente licence.
|
||||
|
||||
Les éventuels « Droits de propriété intellectuelle » détenus par le « Concédant » sur l’ « Information » ne font pas obstacle aux libertés qui sont accordées par la présente licence. Lorsque le « Concédant » détient des « Droits de propriété intellectuelle » » sur l’ « Information », il les cède au « Réutilisateur » de façon non exclusive, à titre gracieux, pour le monde entier, pour toute la durée des « Droits de propriété intellectuelle », et le « Réutilisateur » peut en faire tout usage conformément aux libertés et aux conditions définies par la présente licence.
|
||||
|
||||
## Responsabilité
|
||||
|
||||
L’ «Information» est mise à disposition telle que produite ou reçue, sans autre garantie expresse ou tacite qui n’est pas prévue par la présente licence. L’absence de défauts ou d’erreurs éventuellement contenues dans l’ «Information», comme la fourniture continue de l’ « Information » n’est pas garantie par le «Concédant». Il ne peut être tenu pour responsable de toute perte, préjudice ou dommage de quelque sorte causé à des tiers du fait de la « Réutilisation ».
|
||||
|
||||
Le « Réutilisateur » est seul responsable de la « Réutilisation » de l’« Information ».
|
||||
|
||||
La « Réutilisation » ne doit pas induire en erreur des tiers quant au contenu de l’« Information », sa source et sa date de mise à jour.
|
||||
|
||||
## Droit applicable
|
||||
|
||||
La présente licence est régie par le droit français.
|
||||
|
||||
### Compatibilité de la présente licence
|
||||
|
||||
Elle a été conçue pour être compatible avec toute licence libre qui exige _a minima_ la mention de paternité. Elle est notamment compatible avec la version antérieure de la présente licence ainsi qu’avec les licences « Open Government Licence » (OGL) du Royaume-Uni, « Creative Commons Attribution » (CC-BY) de Creative Commons et « Open Data Commons Attribution » (ODC-BY) de l’Open Knowledge Foundation.
|
||||
|
||||
## Définitions
|
||||
|
||||
Sont considérés, au sens de la présente licence comme :
|
||||
|
||||
- Le « **Concédant** » : toute personne concédant un droit de « Réutilisation » sur l’« Information » dans les libertés et les conditions prévues par la présente licence.
|
||||
- L’« **Information** » :
|
||||
- toute information publique figurant dans des documents communiqués ou publiés par une administration mentionnée au premier alinéa de l’article L.300-2 du CRPA ;
|
||||
- toute information mise à disposition par toute personne selon les termes et conditions de la présente licence.
|
||||
- La « **Réutilisation** » : l’utilisation de l’« Information » à d’autres fins que celles pour lesquelles elle a été produite ou reçue.
|
||||
- Le « **Réutilisateur** » : toute personne qui réutilise les « Informations » conformément aux conditions de la présente licence.
|
||||
- Des « **Données à caractère personnel** » : toute information se rapportant à une personne physique identifiée ou identifiable, pouvant être identifiée directement ou indirectement. Leur « Réutilisation » est subordonnée au respect du cadre juridique en vigueur.
|
||||
- Une « **Information dérivée** » : toute nouvelle donnée ou information créées directement à partir de l’« Information » ou à partir d’une combinaison de l’ « Information » et d’autres données ou informations non soumises à cette licence.
|
||||
- Les « **Droits de propriété intellectuelle** » : tous droits identifiés comme tels par le Code de la propriété intellectuelle (droit d’auteur, droits voisins au droit d’auteur, droit sui generis des producteurs de bases de données).
|
||||
|
||||
## À propos de cette licence
|
||||
|
||||
La présente licence a vocation à être utilisée par les administrations pour la réutilisation de leurs informations publiques. Elle peut également être utilisée par toute personne souhaitant mettre à disposition de l’« Information » dans les conditions définies par la présente licence
|
||||
|
||||
La France est dotée d’un cadre juridique global visant à une diffusion spontanée par les administrations de leurs informations publiques afin d’en permettre la plus large réutilisation.
|
||||
|
||||
Le droit de la « Réutilisation » de l’« Information » des administrations est régi par le code des relations entre le public et l’administration (CRPA) et, le cas échéant, le code du patrimoine (livre II relatif aux archives).
|
||||
|
||||
Cette licence facilite la réutilisation libre et gratuite des informations publiques et figure parmi les licences qui peuvent être utilisées par l’administration en vertu du décret pris en application de l’article L.323-2 du CRPA.
|
||||
|
||||
Etalab est la mission chargée, sous l’autorité du Premier ministre, d’ouvrir le plus grand nombre de données publiques des administrations de l’État et de ses établissements publics. Elle a réalisé la Licence Ouverte pour faciliter la réutilisation libre et gratuite de ces informations publiques, telles que définies par l’article L321-1 du CRPA.
|
||||
|
||||
Cette licence est une version 2.0 de la Licence Ouverte.
|
||||
|
||||
Etalab se réserve la faculté de proposer de nouvelles versions de la Licence Ouverte. Cependant, les « Réutilisateurs » pourront continuer à réutiliser les informations disponibles sous cette licence s’ils le souhaitent.
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Direction Interministérielle du Numérique - Gouvernement Français
|
||||
Copyright (c) 2021-2023 DINUM/Etalab
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -88,10 +88,20 @@ bootstrap: \
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev --no-cache
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend
|
||||
@$(MAKE) build-frontend
|
||||
.PHONY: build
|
||||
|
||||
build-backend: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev
|
||||
.PHONY: build-backend
|
||||
|
||||
|
||||
build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend
|
||||
.PHONY: build-frontend
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
@@ -100,10 +110,16 @@ logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
run-backend: ## start only the backend application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run-backend
|
||||
|
||||
run:
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(MAKE) run-backend
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@@ -114,6 +130,25 @@ stop: ## stop the development server using Docker
|
||||
@$(COMPOSE) stop
|
||||
.PHONY: stop
|
||||
|
||||
# -- Front
|
||||
|
||||
frontend-development-install: ## install the frontend locally
|
||||
cd $(PATH_FRONT) && npm i
|
||||
.PHONY: frontend-development-install
|
||||
|
||||
frontend-lint: ## run the frontend linter
|
||||
cd $(PATH_FRONT) && npm run lint
|
||||
.PHONY: frontend-lint
|
||||
|
||||
frontend-format: ## run the frontend format
|
||||
cd $(PATH_FRONT) && npm run format
|
||||
.PHONY: frontend-format
|
||||
|
||||
run-frontend-development: ## run the frontend in development mode
|
||||
@$(COMPOSE) stop frontend
|
||||
cd $(PATH_FRONT) && npm run dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
# -- Backend
|
||||
|
||||
demo: ## flush db then create a demo for load testing purpose
|
||||
|
||||
@@ -1,157 +1,119 @@
|
||||
# Meet
|
||||
|
||||
Meet is a simple video and phone conferencing tool, powered by [LiveKit](https://livekit.io/).
|
||||
|
||||
Meet is built on top of [Django Rest
|
||||
Framework](https://www.django-rest-framework.org/) and [Vite.js](https://vitejs.dev/).
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisite
|
||||
|
||||
#### Docker
|
||||
|
||||
Make sure you have a recent version of Docker and [Docker
|
||||
Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
|
||||
```bash
|
||||
$ docker -v
|
||||
Docker version 20.10.2, build 2291f61
|
||||
|
||||
$ docker compose -v
|
||||
docker compose version 1.27.4, build 40524192
|
||||
```
|
||||
|
||||
> ⚠️ You may need to run the following commands with `sudo` but this can be
|
||||
> avoided by assigning your user to the `docker` group.
|
||||
|
||||
#### LiveKit CLI
|
||||
|
||||
Install LiveKit CLI, which provides utilities for interacting with the LiveKit ecosystem (including the server, egress, and more), please follow the instructions available in the [official repository](https://github.com/livekit/livekit-cli).
|
||||
|
||||
### Project bootstrap
|
||||
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
```bash
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
Then you can access to the project in development mode by going to http://localhost:3000.
|
||||
You will be prompted to log in, the default credentials are:
|
||||
```bash
|
||||
username: meet
|
||||
password: meet
|
||||
```
|
||||
---
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs
|
||||
database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
dependency-related or migration-related issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
[FIXME] Explain how to run the frontend project.
|
||||
|
||||
### Configure LiveKit CLI
|
||||
|
||||
For the optimal DX, create a default project named `meet` to use with `livekit-cli` commands:
|
||||
```bash
|
||||
$ livekit-cli project add
|
||||
URL: http://localhost:7880
|
||||
API Key: devkey
|
||||
API Secret: secret
|
||||
Give it a name for later reference: meet
|
||||
? Make this project default?? [y/N] y
|
||||
```
|
||||
|
||||
Thus, you won't need to pass the project API Key and API Secret for each command.
|
||||
<p align="center">
|
||||
<img alt="posthoglogo" src="./docs/assets/visio-logo.png" maxWidth="100%">
|
||||
</p>
|
||||
|
||||
|
||||
### Adding content
|
||||
<p align="center">
|
||||
<a href="https://github.com/suitenumerique/meet/stargazers/">
|
||||
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
|
||||
</a>
|
||||
<a href='http://makeapullrequest.com'><img alt='PRs Welcome' src='https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields'/></a>
|
||||
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/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">
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
You can create a basic demo site by running:
|
||||
<p align="center">
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041">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>
|
||||
|
||||
```bash
|
||||
$ make demo
|
||||
```
|
||||
<p align="center">
|
||||
<a href="https://visio.numerique.gouv.fr/">
|
||||
<img src="https://github.com/user-attachments/assets/09c1faa1-de88-4848-af3a-6fbe793999bf" alt="Visio Demonstration">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Finally, you can check all available Make rules using:
|
||||
## Visio: Simple Video Conferencing
|
||||
|
||||
```bash
|
||||
$ make help
|
||||
```
|
||||
|
||||
### Django admin
|
||||
|
||||
You can access the Django admin site at
|
||||
[http://localhost:8071/admin](http://localhost:8071/admin).
|
||||
|
||||
You first need to create a superuser account:
|
||||
|
||||
```bash
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
### Run application on local Kubernetes
|
||||
|
||||
The application is deployed across staging, preprod, and production environments using Kubernetes (K8s).
|
||||
Reproducing environment conditions locally is crucial for developing new features or debugging issues.
|
||||
|
||||
This is facilitated by [Tilt](https://tilt.dev/) ("Kubernetes for Prod, Tilt for Dev"). Tilt enables smart rebuilds and live updates for services running locally in Kubernetes. We defined our services in a Tiltfile located at `bin/Tiltfile`.
|
||||
Powered by [LiveKit](https://livekit.io/), Visio offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||
### Features
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- End-to-end encryption (coming soon)
|
||||
- Meeting recording (coming soon)
|
||||
- Meeting transcription (currently in beta)
|
||||
- Telephony integration (in development)
|
||||
- Secure participation with robust authentication and access control
|
||||
- LiveKit Advances features including :
|
||||
- speaker detection
|
||||
- simulcast
|
||||
- end-to-end optimizations
|
||||
- selective subscription
|
||||
- SVC codecs (VP9, AV1)
|
||||
|
||||
|
||||
#### Getting Started
|
||||
Visio 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).
|
||||
|
||||
Make sure you have installed:
|
||||
- kubectl
|
||||
- helm
|
||||
- helmfile
|
||||
- tilt
|
||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||
|
||||
To build and start the Kubernetes cluster using Kind:
|
||||
```shell
|
||||
$ make build-k8s-cluster
|
||||
```
|
||||
|
||||
Once the Kubernetes cluster is ready, start the application stack locally:
|
||||
```shell
|
||||
$ make start-tilt
|
||||
or
|
||||
$ make start-tilt-keycloak # start stack without Pro Connect, use keycloak
|
||||
```
|
||||
These commands set up and run your application environment using Tilt for local Kubernetes development.
|
||||
## Table of Contents
|
||||
|
||||
You can monitor Tilt's at `http://localhost:10350/`. After Tilt actions finish, you can access the app at `https://meet.127.0.0.1.nip.io/`.
|
||||
- [Get started](#get-started)
|
||||
- [Docs](#docs)
|
||||
- [Contributing](#contributing)
|
||||
- [Philosophy](#philosophy)
|
||||
- [Open source](#open-source)
|
||||
|
||||
#### Debugging frontend
|
||||
|
||||
Tilt deploys the `meet-dev` for the frontend by default, to benefit from Vite.js hot reloading while developing.
|
||||
To troubleshoot production issues, please modify the Tiltfile, switch frontend's target to `frontend-production`:
|
||||
## Get started
|
||||
|
||||
```yaml
|
||||
...
|
||||
### Visio Cloud (Recommended)
|
||||
Sign up for Visio Cloud, designed for European public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-frontend:latest',
|
||||
context='..',
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
target='frontend-production', # Update this line when needed
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
)
|
||||
...
|
||||
```
|
||||
### Open-source deployment (Advanced)
|
||||
|
||||
Deploy Visio on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
|
||||
|
||||
## Docs
|
||||
|
||||
We're currently working on both technical and user documentation for Visio. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
get in touch if you have any question related to our implementation or design
|
||||
decisions.
|
||||
We <3 contributions of any kind, big and small:
|
||||
|
||||
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/3/views/2)
|
||||
- Open a PR (see our instructions on [developing Visio locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
||||
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
||||
|
||||
|
||||
## Philosophy
|
||||
|
||||
We’re relentlessly focused on building the best open-source video conferencing product—Visio. Growth comes from creating something people truly need, not just from chasing metrics.
|
||||
|
||||
Our users come first. We’re committed to making Visio as accessible and easy to use as proprietary solutions, ensuring it meets the highest standards.
|
||||
|
||||
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
|
||||
|
||||
|
||||
## Open-source
|
||||
|
||||
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
|
||||
|
||||
All features we develop will always remain open-source, and we are committed to contributing back to the LiveKit community whenever feasible.
|
||||
To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
### Help us!
|
||||
|
||||
Come help us make Visio even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
|
||||
## Contributors 🧞
|
||||
|
||||
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
|
||||
</a>
|
||||
|
||||
## Credits
|
||||
|
||||
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!
|
||||
|
||||
## License
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](./LICENSE)).
|
||||
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).
|
||||
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Security is very important to us.
|
||||
|
||||
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
|
||||
|
||||
We appreciate your effort to make Visio more secure.
|
||||
|
||||
## Vulnerability disclosure policy
|
||||
|
||||
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
|
||||
|
||||
1. The Maintainers team will handle the fix as usual (Pull Request,
|
||||
release).
|
||||
2. In the release notes, we will include the identification numbers from the
|
||||
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
|
||||
and Exposures (CVE) identifier for the vulnerability.
|
||||
3. Once this grace period has passed, we will publish the vulnerability.
|
||||
|
||||
By adhering to this security policy, we aim to address security concerns
|
||||
effectively and responsibly in our open source software project.
|
||||
@@ -1,6 +1,10 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('meet')
|
||||
|
||||
def clean_old_images(image_name):
|
||||
local('docker images -q %s | tail -n +2 | xargs -r docker rmi' % image_name)
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-backend:latest',
|
||||
context='..',
|
||||
@@ -15,6 +19,7 @@ docker_build(
|
||||
)
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-backend')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-frontend:latest',
|
||||
@@ -26,6 +31,7 @@ docker_build(
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-frontend')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-summary:latest',
|
||||
@@ -37,6 +43,24 @@ docker_build(
|
||||
sync('../src/summary', '/home/summary'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-summary')
|
||||
|
||||
# Copy the mkcert root CA certificate to our Docker build context
|
||||
# This is necessary because we need to inject the certificate into our LiveKit container
|
||||
local_resource(
|
||||
'copy-root-ca',
|
||||
cmd='cp "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
|
||||
deps=[], # No dependencies needed
|
||||
)
|
||||
# Build a custom LiveKit Docker image that includes our root CA certificate
|
||||
# This allows LiveKit to trust our local development certificates
|
||||
docker_build(
|
||||
'localhost:5001/meet-livekit:latest',
|
||||
context='../docker/livekit',
|
||||
dockerfile='./../docker/livekit/Dockerfile',
|
||||
only=['.'],
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-livekit')
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
|
||||
|
||||
+13
@@ -95,8 +95,21 @@ services:
|
||||
depends_on:
|
||||
- keycloak
|
||||
|
||||
frontend:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
args:
|
||||
VITE_API_BASE_URL: "http://localhost:8071"
|
||||
image: meet:frontend-development
|
||||
ports:
|
||||
- "3000:8080"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
platform: linux/x86_64
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:4.0.0
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM livekit/livekit-server:latest
|
||||
|
||||
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
||||
COPY rootCA.pem /etc/ssl/certs/
|
||||
|
||||
ENTRYPOINT ["/livekit-server"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,145 @@
|
||||
# Getting Started
|
||||
|
||||
Before setting up, let's review Visio's architecture.
|
||||
|
||||
Visio consists of four main components that run simultaneously:
|
||||
|
||||
- React frontend, built with Vite.js
|
||||
- Django server
|
||||
- LiveKit server
|
||||
- FastAPI server (optional, required for AI beta features)
|
||||
|
||||
These components rely on a few key services:
|
||||
|
||||
- PostgreSQL for storing data (users, rooms, recordings)
|
||||
- Redis for caching and inter-service communication
|
||||
- MinIO for storing files (room recordings)
|
||||
- Celery workers for meeting transcript (optional, required for AI beta features)
|
||||
|
||||
We provide two stack options for getting Visio up and running for development:
|
||||
|
||||
- Docker Compose stack (recommended for most users)
|
||||
- Kubernetes stack powered by Tilt (Advanced)
|
||||
|
||||
We recommend starting with the **Docker Compose** option for simplicity. However, if you're comfortable with running Kubernetes locally, the advanced option mirrors the production environment and provides most of the tools required for development (e.g., hot reloading).
|
||||
|
||||
These instructions are for macOS or Ubuntu. For other distros, adjust as needed.
|
||||
|
||||
If any steps are outdated, please let us know!
|
||||
|
||||
---
|
||||
|
||||
We also provide **GNU make utilities**. To view all available Make rules, run:
|
||||
```shellscript
|
||||
$ make help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Need Help?
|
||||
If you need any assistance or have questions while getting started, feel free to reach out to @lebaudantoine anytime! Antoine is available to help you onboard and guide you through the process. Chat with him @antoine.lebaud:matrix.org, or from the [support hotline](https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041).
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Developing with Docker
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Ensure you have a recent version of **Docker** and **Docker Compose** installed:
|
||||
```shellscript
|
||||
$ docker -v
|
||||
Docker version 20.10.2, build 2291f61
|
||||
|
||||
$ docker compose version
|
||||
Docker Compose version v2.32.4
|
||||
```
|
||||
|
||||
2. Install **LiveKit CLI** by following the instructions available in the [official repository](https://github.com/livekit/livekit-cli). After installation, verify that it's working:
|
||||
```shellscript
|
||||
$ lk --version
|
||||
lk version 2.3.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Project Bootstrap
|
||||
|
||||
1. Bootstrap the project using the **Make** command. This will build the `app` container, install dependencies, run database migrations, and compile translations:
|
||||
```shellscript
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
2. Access the project:
|
||||
- The frontend is available at [http://localhost:3000](http://localhost:3000) with the default credentials:
|
||||
- username: meet
|
||||
- password: meet
|
||||
- The Django backend is available at [http://localhost:8071](http://localhost:8071)
|
||||
|
||||
---
|
||||
|
||||
## Developing
|
||||
|
||||
- To **stop** the application:
|
||||
```shellscript
|
||||
$ make stop
|
||||
```
|
||||
|
||||
- To **restart** the application:
|
||||
```shellscript
|
||||
$ make run
|
||||
```
|
||||
|
||||
- For **frontend development**, start all backend services without the frontend container:
|
||||
```shellscript
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
Then:
|
||||
```shellscript
|
||||
$ make frontend-development-install
|
||||
$ make run-frontend-development
|
||||
```
|
||||
|
||||
Which is equivalent to these direct npm commands:
|
||||
```shellscript
|
||||
$ cd src/frontend
|
||||
$ npm i
|
||||
$ npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding Content
|
||||
|
||||
You can bootstrap demo data with a single command:
|
||||
```shellscript
|
||||
$ make demo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Developing with Kubernetes
|
||||
|
||||
Visio is deployed across staging, preprod, and production environments using **Kubernetes (K8s)**. Reproducing the environment locally is crucial for developing new features or debugging.
|
||||
|
||||
This is facilitated by [Tilt](https://tilt.dev/), which provides Kubernetes-like development for local environments, enabling smart rebuilds and live updates.
|
||||
|
||||
### Getting Started
|
||||
|
||||
Make sure you have the following installed:
|
||||
- kubectl
|
||||
- helm
|
||||
- helmfile
|
||||
- tilt
|
||||
|
||||
To build and start the Kubernetes cluster using **Kind**:
|
||||
```shellscript
|
||||
$ make build-k8s-cluster
|
||||
```
|
||||
|
||||
Once the Kubernetes cluster is ready, start the application stack locally:
|
||||
```shellscript
|
||||
$ make start-tilt-keycloak
|
||||
```
|
||||
|
||||
Monitor Tilt’s progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://visio.127.0.0.1.nip.io/](https://visio.127.0.0.1.nip.io/).
|
||||
@@ -212,7 +212,7 @@ POSTGRES_PASSWORD: pass
|
||||
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
|
||||
|
||||
```
|
||||
$ helm repo add meet https://numerique-gouv.github.io/meet/
|
||||
$ helm repo add meet https://suitenumerique.github.io/meet/
|
||||
$ helm repo update
|
||||
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||
```
|
||||
|
||||
-1
Submodule secrets deleted from 2ba12db71d
@@ -1,6 +1,5 @@
|
||||
"""Client serializers for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
@@ -92,7 +91,7 @@ class ListRoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "is_public"]
|
||||
fields = ["id", "name", "slug", "access_level"]
|
||||
read_only_fields = ["id", "slug"]
|
||||
|
||||
|
||||
@@ -101,7 +100,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "configuration", "is_public"]
|
||||
fields = ["id", "name", "slug", "configuration", "access_level"]
|
||||
read_only_fields = ["id", "slug"]
|
||||
|
||||
def to_representation(self, instance):
|
||||
@@ -129,17 +128,21 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
if not is_admin:
|
||||
del output["configuration"]
|
||||
|
||||
if role is not None or instance.is_public:
|
||||
slug = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
should_access_room = (
|
||||
(
|
||||
instance.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and request.user.is_authenticated
|
||||
)
|
||||
or role is not None
|
||||
or instance.is_public
|
||||
)
|
||||
|
||||
output["livekit"] = {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": slug,
|
||||
"token": utils.generate_token(
|
||||
room=slug, user=request.user, username=username
|
||||
),
|
||||
}
|
||||
if should_access_room:
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
output["livekit"] = utils.generate_livekit_config(
|
||||
room_id=room_id, user=request.user, username=username
|
||||
)
|
||||
|
||||
output["is_administrable"] = is_admin
|
||||
|
||||
@@ -177,3 +180,32 @@ class StartRecordingSerializer(serializers.Serializer):
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class RequestEntrySerializer(serializers.Serializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class ParticipantEntrySerializer(serializers.Serializer):
|
||||
"""Validate participant entry decision data."""
|
||||
|
||||
participant_id = serializers.CharField(required=True)
|
||||
allow_entry = serializers.BooleanField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
@@ -9,12 +9,7 @@ from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.text import slugify
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
mixins,
|
||||
pagination,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import decorators, mixins, pagination, throttling, viewsets
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
@@ -44,6 +39,14 @@ from core.recording.worker.factories import (
|
||||
from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
from core.services.livekit_events import (
|
||||
LiveKitEventsService,
|
||||
LiveKitWebhookError,
|
||||
)
|
||||
from core.services.lobby import (
|
||||
LobbyParticipantNotFound,
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
@@ -151,9 +154,8 @@ class UserViewSet(
|
||||
queryset = self.queryset
|
||||
|
||||
if self.action == "list":
|
||||
# Exclude all users already in the given document
|
||||
if document_id := self.request.GET.get("document_id", ""):
|
||||
queryset = queryset.exclude(documentaccess__document_id=document_id)
|
||||
if not settings.ALLOW_UNSECURE_USER_LISTING:
|
||||
return models.User.objects.none()
|
||||
|
||||
# Filter users by email similarity
|
||||
if query := self.request.GET.get("q", ""):
|
||||
@@ -178,6 +180,12 @@ class UserViewSet(
|
||||
)
|
||||
|
||||
|
||||
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
@@ -343,6 +351,115 @@ class RoomViewSet(
|
||||
{"message": f"Recording stopped for room {room.slug}."}
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["POST"],
|
||||
url_path="request-entry",
|
||||
permission_classes=[],
|
||||
throttle_classes=[RequestEntryAnonRateThrottle],
|
||||
)
|
||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Request entry to a room"""
|
||||
|
||||
serializer = serializers.RequestEntrySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
room = self.get_object()
|
||||
lobby_service = LobbyService()
|
||||
|
||||
participant, livekit = lobby_service.request_entry(
|
||||
room=room,
|
||||
request=request,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||
lobby_service.prepare_response(response, participant.id)
|
||||
|
||||
return response
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="enter",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Accept or deny a participant's entry request."""
|
||||
|
||||
serializer = serializers.ParticipantEntrySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_public:
|
||||
return drf_response.Response(
|
||||
{"message": "Room has no lobby system."},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
lobby_service = LobbyService()
|
||||
|
||||
try:
|
||||
lobby_service.handle_participant_entry(
|
||||
room_id=room.id,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
return drf_response.Response({"message": "Participant was updated."})
|
||||
|
||||
except LobbyParticipantNotFound:
|
||||
return drf_response.Response(
|
||||
{"message": "Participant not found."},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["GET"],
|
||||
url_path="waiting-participants",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""List waiting participants."""
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_public:
|
||||
return drf_response.Response({"participants": []})
|
||||
|
||||
lobby_service = LobbyService()
|
||||
|
||||
participants = lobby_service.list_waiting_participants(room.id)
|
||||
return drf_response.Response({"participants": participants})
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="webhooks-livekit",
|
||||
permission_classes=[],
|
||||
)
|
||||
def webhooks_livekit(self, request):
|
||||
"""Process webhooks from LiveKit."""
|
||||
|
||||
livekit_events_service = LiveKitEventsService()
|
||||
|
||||
try:
|
||||
livekit_events_service.receive(request)
|
||||
return drf_response.Response(
|
||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
except LiveKitWebhookError as e:
|
||||
status_code = getattr(e, "status_code", drf_status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||
raise e
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "error", "message": str(e)}, status=status_code
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
|
||||
@@ -10,7 +10,7 @@ from mozilla_django_oidc.auth import (
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
from core.services.marketing_service import (
|
||||
from core.services.marketing import (
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
|
||||
@@ -36,8 +36,6 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
||||
model = models.Resource
|
||||
skip_postgeneration_save = True
|
||||
|
||||
is_public = factory.Faker("boolean", chance_of_getting_true=50)
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
"""Add users to resource from a given list of users."""
|
||||
@@ -70,6 +68,7 @@ class RoomFactory(ResourceFactory):
|
||||
|
||||
name = factory.Faker("catch_phrase")
|
||||
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
||||
access_level = factory.fuzzy.FuzzyChoice(models.RoomAccessLevel)
|
||||
|
||||
|
||||
class RecordingFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-16 11:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_alter_resourceaccess_options_alter_user_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='resource',
|
||||
name='is_public',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='access_level',
|
||||
field=models.CharField(choices=[('public', 'Public Access'), ('restricted', 'Restricted Access')], default='public', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-04 09:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_remove_resource_is_public_room_access_level'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='room',
|
||||
name='access_level',
|
||||
field=models.CharField(choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], default='public', max_length=50),
|
||||
)
|
||||
]
|
||||
@@ -80,6 +80,14 @@ class RecordingModeChoices(models.TextChoices):
|
||||
TRANSCRIPT = "transcript", _("TRANSCRIPT")
|
||||
|
||||
|
||||
class RoomAccessLevel(models.TextChoices):
|
||||
"""Room access level choices."""
|
||||
|
||||
PUBLIC = "public", _("Public Access")
|
||||
TRUSTED = "trusted", _("Trusted Access")
|
||||
RESTRICTED = "restricted", _("Restricted Access")
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -241,7 +249,6 @@ def get_resource_roles(resource: models.Model, user: User) -> List[str]:
|
||||
class Resource(BaseModel):
|
||||
"""Model to define access control"""
|
||||
|
||||
is_public = models.BooleanField(default=settings.RESOURCE_DEFAULT_IS_PUBLIC)
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
through="ResourceAccess",
|
||||
@@ -361,7 +368,11 @@ class Room(Resource):
|
||||
primary_key=True,
|
||||
)
|
||||
slug = models.SlugField(max_length=100, blank=True, null=True, unique=True)
|
||||
|
||||
access_level = models.CharField(
|
||||
max_length=50,
|
||||
choices=RoomAccessLevel.choices,
|
||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
)
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
@@ -394,6 +405,11 @@ class Room(Resource):
|
||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
@property
|
||||
def is_public(self):
|
||||
"""Check if a room is public"""
|
||||
return self.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
class BaseAccessManager(models.Manager):
|
||||
"""Base manager for handling resource access control."""
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""LiveKit Events Service"""
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from .lobby import LobbyService
|
||||
|
||||
|
||||
class LiveKitWebhookError(Exception):
|
||||
"""Base exception for LiveKit webhook processing errors."""
|
||||
|
||||
status_code = 500
|
||||
|
||||
|
||||
class AuthenticationError(LiveKitWebhookError):
|
||||
"""Authentication failed."""
|
||||
|
||||
status_code = 401
|
||||
|
||||
|
||||
class InvalidPayloadError(LiveKitWebhookError):
|
||||
"""Invalid webhook payload."""
|
||||
|
||||
status_code = 400
|
||||
|
||||
|
||||
class UnsupportedEventTypeError(LiveKitWebhookError):
|
||||
"""Unsupported event type."""
|
||||
|
||||
status_code = 422
|
||||
|
||||
|
||||
class ActionFailedError(LiveKitWebhookError):
|
||||
"""Webhook action fails to process or complete."""
|
||||
|
||||
status_code = 500
|
||||
|
||||
|
||||
class LiveKitWebhookEventType(Enum):
|
||||
"""LiveKit webhook event types."""
|
||||
|
||||
# Room events
|
||||
ROOM_STARTED = "room_started"
|
||||
ROOM_FINISHED = "room_finished"
|
||||
|
||||
# Participant events
|
||||
PARTICIPANT_JOINED = "participant_joined"
|
||||
PARTICIPANT_LEFT = "participant_left"
|
||||
|
||||
# Track events
|
||||
TRACK_PUBLISHED = "track_published"
|
||||
TRACK_UNPUBLISHED = "track_unpublished"
|
||||
|
||||
# Egress events
|
||||
EGRESS_STARTED = "egress_started"
|
||||
EGRESS_UPDATED = "egress_updated"
|
||||
EGRESS_ENDED = "egress_ended"
|
||||
|
||||
# Ingress events
|
||||
INGRESS_STARTED = "ingress_started"
|
||||
INGRESS_ENDED = "ingress_ended"
|
||||
|
||||
|
||||
class LiveKitEventsService:
|
||||
"""Service for processing and handling LiveKit webhook events and notifications."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize with required services."""
|
||||
|
||||
token_verifier = api.TokenVerifier(
|
||||
settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
|
||||
def receive(self, request):
|
||||
"""Process webhook and route to appropriate handler."""
|
||||
|
||||
auth_token = request.headers.get("Authorization")
|
||||
if not auth_token:
|
||||
raise AuthenticationError("Authorization header missing")
|
||||
|
||||
try:
|
||||
data = self.webhook_receiver.receive(
|
||||
request.body.decode("utf-8"), auth_token
|
||||
)
|
||||
except Exception as e:
|
||||
raise InvalidPayloadError("Invalid webhook payload") from e
|
||||
|
||||
try:
|
||||
webhook_type = LiveKitWebhookEventType(data.event)
|
||||
except ValueError as e:
|
||||
raise UnsupportedEventTypeError(
|
||||
f"Unknown webhook type: {data.event}"
|
||||
) from e
|
||||
|
||||
handler_name = f"_handle_{webhook_type.value}"
|
||||
handler = getattr(self, handler_name, None)
|
||||
|
||||
if not handler or not callable(handler):
|
||||
return
|
||||
|
||||
# pylint: disable=not-callable
|
||||
handler(data)
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
"""Handle 'room_finished' event."""
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
self.lobby_service.clear_room_cache(room_id)
|
||||
except Exception as e:
|
||||
raise ActionFailedError("Failed to process room finished event") from e
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Lobby Service"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import LiveKitAPI, SendDataRequest, TwirpError # pylint: disable=E0611
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LobbyParticipantStatus(Enum):
|
||||
"""Possible states of a participant in the lobby system.
|
||||
Values are lowercase strings for consistent serialization and API responses.
|
||||
"""
|
||||
|
||||
UNKNOWN = "unknown"
|
||||
WAITING = "waiting"
|
||||
ACCEPTED = "accepted"
|
||||
DENIED = "denied"
|
||||
|
||||
|
||||
class LobbyError(Exception):
|
||||
"""Base exception for lobby-related errors."""
|
||||
|
||||
|
||||
class LobbyParticipantParsingError(LobbyError):
|
||||
"""Raised when participant data parsing fails."""
|
||||
|
||||
|
||||
class LobbyParticipantNotFound(LobbyError):
|
||||
"""Raised when participant is not found."""
|
||||
|
||||
|
||||
class LobbyNotificationError(LobbyError):
|
||||
"""Raised when LiveKit notification fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LobbyParticipant:
|
||||
"""Participant in a lobby system."""
|
||||
|
||||
status: LobbyParticipantStatus
|
||||
username: str
|
||||
color: str
|
||||
id: str
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
"""Serialize the participant object to a dict representation."""
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"username": self.username,
|
||||
"id": self.id,
|
||||
"color": self.color,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "LobbyParticipant":
|
||||
"""Create a LobbyParticipant instance from a dictionary."""
|
||||
try:
|
||||
status = LobbyParticipantStatus(
|
||||
data.get("status", LobbyParticipantStatus.UNKNOWN.value)
|
||||
)
|
||||
return cls(
|
||||
status=status,
|
||||
username=data["username"],
|
||||
id=data["id"],
|
||||
color=data["color"],
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.exception("Error creating Participant from dict:")
|
||||
raise LobbyParticipantParsingError("Invalid participant data") from e
|
||||
|
||||
|
||||
class LobbyService:
|
||||
"""Service for managing participant access through a lobby system.
|
||||
|
||||
Handles participant entry requests, status management, and notifications
|
||||
using cache for state management and LiveKit for real-time updates.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_cache_key(room_id: UUID, participant_id: str) -> str:
|
||||
"""Generate cache key for participant(s) data."""
|
||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
|
||||
@staticmethod
|
||||
def _get_or_create_participant_id(request) -> str:
|
||||
"""Extract unique participant identifier from the request."""
|
||||
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, uuid.uuid4().hex)
|
||||
|
||||
@staticmethod
|
||||
def prepare_response(response, participant_id):
|
||||
"""Set participant cookie if needed."""
|
||||
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
|
||||
response.set_cookie(
|
||||
key=settings.LOBBY_COOKIE_NAME,
|
||||
value=participant_id,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def can_bypass_lobby(room, user) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
|
||||
A user can bypass the lobby if:
|
||||
1. The room is public (open to everyone)
|
||||
2. The room has TRUSTED access level and the user is authenticated
|
||||
|
||||
Note: Room access levels can change while participants are waiting in the lobby.
|
||||
This function only checks the current state and should be called each time
|
||||
a participant requests entry to ensure consistent access control, even for
|
||||
participants who have already begun waiting.
|
||||
"""
|
||||
return room.is_public or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
)
|
||||
|
||||
def request_entry(
|
||||
self,
|
||||
room,
|
||||
request,
|
||||
username: str,
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
"""Request entry to a room for a participant.
|
||||
|
||||
This usual status transitions is:
|
||||
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
|
||||
|
||||
Flow:
|
||||
1. Check current status
|
||||
2. If waiting, refresh timeout to maintain position
|
||||
3. If unknown, add to waiting list
|
||||
4. If accepted, generate LiveKit config
|
||||
5. If denied, do nothing.
|
||||
"""
|
||||
|
||||
participant_id = self._get_or_create_participant_id(request)
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
if self.can_bypass_lobby(room=room, user=request.user):
|
||||
if participant is None:
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
livekit_config = None
|
||||
|
||||
if participant is None:
|
||||
participant = self.enter(room.id, participant_id, username)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||
self.refresh_waiting_status(room.id, participant_id)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# wrongly named, contains access token to join a room
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
|
||||
def refresh_waiting_status(self, room_id: UUID, participant_id: str):
|
||||
"""Refresh timeout for waiting participant.
|
||||
|
||||
Extends the waiting period for a participant to maintain their position
|
||||
in the lobby queue. Automatic removal if the participant is not
|
||||
actively checking their status.
|
||||
"""
|
||||
cache.touch(
|
||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
|
||||
def enter(
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby.
|
||||
|
||||
Create a new participant entry in waiting status and notify room
|
||||
participants of the new entry request.
|
||||
|
||||
Raises:
|
||||
LobbyNotificationError: If room notification fails
|
||||
"""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=color,
|
||||
)
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.set(
|
||||
cache_key,
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
|
||||
try:
|
||||
self.notify_participants(room_id=room_id)
|
||||
except LobbyNotificationError:
|
||||
# If room not created yet, there is no participants to notify
|
||||
pass
|
||||
|
||||
return participant
|
||||
|
||||
def _get_participant(
|
||||
self, room_id: UUID, participant_id: str
|
||||
) -> Optional[LobbyParticipant]:
|
||||
"""Check participant's current status in the lobby."""
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
data = cache.get(cache_key)
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
try:
|
||||
return LobbyParticipant.from_dict(data)
|
||||
except LobbyParticipantParsingError:
|
||||
logger.error("Corrupted participant data found and removed: %s", cache_key)
|
||||
cache.delete(cache_key)
|
||||
return None
|
||||
|
||||
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
|
||||
"""List all waiting participants for a room."""
|
||||
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = cache.keys(pattern)
|
||||
|
||||
if not keys:
|
||||
return []
|
||||
|
||||
data = cache.get_many(keys)
|
||||
|
||||
waiting_participants = []
|
||||
for cache_key, raw_participant in data.items():
|
||||
try:
|
||||
participant = LobbyParticipant.from_dict(raw_participant)
|
||||
except LobbyParticipantParsingError:
|
||||
cache.delete(cache_key)
|
||||
continue
|
||||
if participant.status == LobbyParticipantStatus.WAITING:
|
||||
waiting_participants.append(participant.to_dict())
|
||||
|
||||
return waiting_participants
|
||||
|
||||
def handle_participant_entry(
|
||||
self,
|
||||
room_id: UUID,
|
||||
participant_id: str,
|
||||
allow_entry: bool,
|
||||
) -> None:
|
||||
"""Handle decision on participant entry.
|
||||
|
||||
Updates participant status based on allow_entry:
|
||||
- If accepted: ACCEPTED status with extended timeout matching LiveKit token
|
||||
- If denied: DENIED status with short timeout allowing status check and retry
|
||||
"""
|
||||
if allow_entry:
|
||||
decision = {
|
||||
"status": LobbyParticipantStatus.ACCEPTED,
|
||||
"timeout": settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
}
|
||||
else:
|
||||
decision = {
|
||||
"status": LobbyParticipantStatus.DENIED,
|
||||
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
||||
}
|
||||
|
||||
self._update_participant_status(room_id, participant_id, **decision)
|
||||
|
||||
def _update_participant_status(
|
||||
self,
|
||||
room_id: UUID,
|
||||
participant_id: str,
|
||||
status: LobbyParticipantStatus,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
"""Update participant status with appropriate timeout."""
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
|
||||
data = cache.get(cache_key)
|
||||
if not data:
|
||||
logger.error("Participant %s not found", participant_id)
|
||||
raise LobbyParticipantNotFound("Participant not found")
|
||||
|
||||
try:
|
||||
participant = LobbyParticipant.from_dict(data)
|
||||
except LobbyParticipantParsingError:
|
||||
logger.exception(
|
||||
"Removed corrupted data for participant %s:", participant_id
|
||||
)
|
||||
cache.delete(cache_key)
|
||||
raise
|
||||
|
||||
participant.status = status
|
||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||
|
||||
@async_to_sync
|
||||
async def notify_participants(self, room_id: UUID):
|
||||
"""Notify room participants about a new waiting participant using LiveKit.
|
||||
|
||||
Raises:
|
||||
LobbyNotificationError: If notification fails to send
|
||||
"""
|
||||
|
||||
notification_data = {
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
}
|
||||
|
||||
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
|
||||
try:
|
||||
await lkapi.room.send_data(
|
||||
SendDataRequest(
|
||||
room=str(room_id),
|
||||
data=json.dumps(notification_data).encode("utf-8"),
|
||||
kind="RELIABLE",
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to notify room participants")
|
||||
raise LobbyNotificationError("Failed to notify room participants") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = cache.keys(pattern)
|
||||
|
||||
if not keys:
|
||||
return
|
||||
|
||||
cache.delete_many(keys)
|
||||
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Generate Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Generate Document</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">Generate PDF</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import UserFactory
|
||||
from core.services import marketing_service
|
||||
from core.services import marketing
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -535,7 +535,7 @@ def test_marketing_signup_handles_service_initialization_errors(
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
marketing_service.ContactCreationError,
|
||||
marketing.ContactCreationError,
|
||||
ImproperlyConfigured,
|
||||
ImportError,
|
||||
],
|
||||
|
||||
@@ -59,8 +59,8 @@ def test_api_recordings_list_authenticated_direct(role):
|
||||
"id": str(recording.id),
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_public": room.is_public,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
|
||||
@@ -9,14 +9,16 @@ from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_rooms_list_anonymous():
|
||||
"""Anonymous users should not be able to list rooms."""
|
||||
RoomFactory(is_public=False)
|
||||
RoomFactory(is_public=True)
|
||||
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
@@ -38,10 +40,13 @@ def test_api_rooms_list_authenticated():
|
||||
|
||||
other_user = UserFactory()
|
||||
|
||||
RoomFactory(is_public=False)
|
||||
RoomFactory(is_public=True)
|
||||
room_user_accesses = RoomFactory(is_public=False, users=[user])
|
||||
RoomFactory(is_public=False, users=[other_user])
|
||||
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room_user_accesses = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, users=[user]
|
||||
)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/rooms/",
|
||||
@@ -105,7 +110,7 @@ def test_api_rooms_list_authenticated_distinct():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
room = RoomFactory(is_public=True, users=[user, other_user])
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC, users=[user, other_user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/rooms/",
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: lobby functionality.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613,W0212
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ... import utils
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...services.lobby import (
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Tests for request_entry endpoint
|
||||
|
||||
|
||||
def test_request_entry_anonymous(settings):
|
||||
"""Anonymous users should be allowed to request entry to a room."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": participant_id,
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
# Verify a participant was stored in cache
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
# Verify participant data was correctly stored in cache
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
def test_request_entry_authenticated_user(settings):
|
||||
"""Authenticated users should be allowed to request entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": participant_id,
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
# Verify a participant was stored in cache
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
# Verify participant data was correctly stored in cache
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
def test_request_entry_with_existing_participants(settings):
|
||||
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
||||
# Create a restricted access room
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
# Configure test settings for cookies and cache
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add two participants already waiting in the lobby
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant2",
|
||||
{
|
||||
"id": "participant2",
|
||||
"username": "user2",
|
||||
"status": "accepted",
|
||||
"color": "#654321",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify two participants are in the lobby before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 2
|
||||
|
||||
# Mock external service calls to isolate the test
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
# Make request as a new anonymous user
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
# Verify successful response
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set for the new participant
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": participant_id,
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
# Verify now three participants are in the lobby cache
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 3
|
||||
|
||||
# Verify the new participant data was correctly stored in cache
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
def test_request_entry_public_room(settings):
|
||||
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "123"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
"username": "test_user",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
# Verify lobby cache is still empty after the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
def test_request_entry_authenticated_user_public_room(settings):
|
||||
"""While authenticated, entry request to public rooms should get accepted."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "123"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
"username": "test_user",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
# Verify lobby cache is still empty after the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
def test_request_entry_waiting_participant_public_room(settings):
|
||||
"""While waiting, entry request to public rooms should get accepted."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add a waiting participant to the room's lobby cache
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
)
|
||||
|
||||
# Simulate a browser with existing participant cookie
|
||||
client.cookies.load({"mocked-cookie": "participant1"})
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "user1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "participant1"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
# Verify participant remains in the lobby cache after acceptance
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
|
||||
def test_request_entry_invalid_data():
|
||||
"""Should return 400 for invalid request data."""
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{}, # Missing required username field
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_request_entry_room_not_found():
|
||||
"""Should return 404 for non-existent room."""
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{uuid.uuid4()!s}/request-entry/",
|
||||
{"username": "anonymous"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# Tests for allow_participant_to_enter endpoint
|
||||
|
||||
|
||||
def test_allow_participant_to_enter_anonymous():
|
||||
"""Anonymous users should not be allowed to manage entry requests."""
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_allow_participant_to_enter_non_owner():
|
||||
"""Non-privileged users should not be allowed to manage entry requests."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_allow_participant_to_enter_public_room():
|
||||
"""Should return 404 for public rooms that don't use the lobby system."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"message": "Room has no lobby system."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allow_entry, updated_status", [(True, "accepted"), (False, "denied")]
|
||||
)
|
||||
def test_allow_participant_to_enter_success(settings, allow_entry, updated_status):
|
||||
"""Should successfully update participant status when everything is correct."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id!s}_participant1",
|
||||
{
|
||||
"id": "test-id",
|
||||
"status": "waiting",
|
||||
"username": "foo",
|
||||
"color": "123",
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "participant1", "allow_entry": allow_entry},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Participant was updated."}
|
||||
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_participant1")
|
||||
assert participant_data.get("status") == updated_status
|
||||
|
||||
|
||||
def test_allow_participant_to_enter_participant_not_found(settings):
|
||||
"""Should handle case when participant is not found."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_test-id")
|
||||
assert participant_data is None
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"message": "Participant not found."}
|
||||
|
||||
|
||||
def test_allow_participant_to_enter_invalid_data():
|
||||
"""Should return 400 for invalid request data."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{}, # Missing required fields
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# Tests for list_waiting_participants endpoint
|
||||
|
||||
|
||||
def test_list_waiting_participants_anonymous():
|
||||
"""Anonymous users should not be allowed to list waiting participants."""
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_list_waiting_participants_non_owner():
|
||||
"""Non-privileged users should not be allowed to list waiting participants."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_list_waiting_participants_public_room():
|
||||
"""Should return empty list for public rooms."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
with mock.patch(
|
||||
"core.api.viewsets.LobbyService", autospec=True
|
||||
) as mocked_lobby_service:
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
# Verify lobby service was not instantiated
|
||||
mocked_lobby_service.assert_not_called()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"participants": []}
|
||||
|
||||
|
||||
def test_list_waiting_participants_success(settings):
|
||||
"""Should successfully return list of waiting participants."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add participants in the lobby
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant2",
|
||||
{
|
||||
"id": "participant2",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
participants = response.json().get("participants")
|
||||
assert sorted(participants, key=lambda p: p["id"]) == [
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
{
|
||||
"id": "participant2",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_list_waiting_participants_empty(settings):
|
||||
"""Should handle case when there are no waiting participants."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"participants": []}
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from ...models import RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -21,15 +22,34 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
Anonymous users should be allowed to retrieve a private room but should not be
|
||||
given any token.
|
||||
"""
|
||||
room = RoomFactory(is_public=False)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"""
|
||||
Anonymous users should be allowed to retrieve a room that has a trusted access_level,
|
||||
but should not be given any token.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_level": "trusted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -37,7 +57,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
|
||||
def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"""It should be possible to get a room by its id stripped of its dashes."""
|
||||
room = RoomFactory(is_public=False)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
id_no_dashes = str(room.id)
|
||||
|
||||
client = APIClient()
|
||||
@@ -45,9 +65,9 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -55,15 +75,15 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
|
||||
def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"""It should be possible to get a room by its slug."""
|
||||
room = RoomFactory(is_public=False)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{room.slug!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -71,15 +91,15 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
|
||||
def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"""Getting a room by a slug that is not normalized should work."""
|
||||
room = RoomFactory(name="Réunion", is_public=False)
|
||||
room = RoomFactory(name="Réunion", access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/rooms/Réunion/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -171,18 +191,18 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
|
||||
)
|
||||
def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"""
|
||||
Anonymous users should be able to retrieve a room with a token provided it is public.
|
||||
Anonymous users should be able to retrieve a room with a token provided, if the room is public.
|
||||
"""
|
||||
room = RoomFactory(is_public=True)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": True,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -209,7 +229,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
which they are not related, provided the room is public.
|
||||
They should not see related users.
|
||||
"""
|
||||
room = RoomFactory(is_public=True)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
@@ -222,9 +242,9 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": True,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -234,7 +254,53 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a room and get a token for a room to
|
||||
which they are not related, provided the room has a trusted access_level.
|
||||
They should not see related users.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_authenticated():
|
||||
@@ -242,7 +308,7 @@ def test_api_rooms_retrieve_authenticated():
|
||||
Authenticated users should be allowed to retrieve a private room to which they
|
||||
are not related but should not be given any token.
|
||||
"""
|
||||
room = RoomFactory(is_public=False)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
@@ -254,9 +320,9 @@ def test_api_rooms_retrieve_authenticated():
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.json() == {
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -324,9 +390,9 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": room.is_public,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -336,7 +402,9 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@@ -400,9 +468,9 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
)
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": True,
|
||||
"is_public": room.is_public,
|
||||
"configuration": {},
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
@@ -413,4 +481,6 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -54,17 +55,18 @@ def test_api_rooms_update_members():
|
||||
not be allowed to update it.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(name="Old name", users=[(user, "member")])
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.PUBLIC, name="Old name", users=[(user, "member")]
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
new_is_public = not room.is_public
|
||||
response = client.put(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"is_public": new_is_public,
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"the_key": "the_value"},
|
||||
},
|
||||
format="json",
|
||||
@@ -73,24 +75,26 @@ def test_api_rooms_update_members():
|
||||
room.refresh_from_db()
|
||||
assert room.name == "Old name"
|
||||
assert room.slug == "old-name"
|
||||
assert room.is_public != new_is_public
|
||||
assert room.access_level != RoomAccessLevel.RESTRICTED
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators():
|
||||
"""Administrators or owners of a room should be allowed to update it."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, random.choice(["administrator", "owner"]))])
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
new_is_public = not room.is_public
|
||||
response = client.put(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"is_public": new_is_public,
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"configuration": {"the_key": "the_value"},
|
||||
},
|
||||
format="json",
|
||||
@@ -99,7 +103,7 @@ def test_api_rooms_update_administrators():
|
||||
room.refresh_from_db()
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
assert room.is_public == new_is_public
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"the_key": "the_value"}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Test LiveKit webhook endpoint on the rooms API.
|
||||
"""
|
||||
|
||||
# ruff: noqa: PLR0913
|
||||
# pylint: disable=R0913,W0621,R0917,W0613
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from livekit import api
|
||||
|
||||
from ...services.livekit_events import ActionFailedError, LiveKitEventsService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def webhook_event_data():
|
||||
"""Sample webhook event data for testing."""
|
||||
return {
|
||||
"event": "room_finished",
|
||||
"room": {
|
||||
"sid": "RM_hycBMAjmt6Ub",
|
||||
"name": "00000000-0000-0000-0000-000000000000",
|
||||
"emptyTimeout": 300,
|
||||
"creationTime": "1692627281",
|
||||
"turnPassword": "2Pvdj+/WV1xV4EkB8klJ9xkXDWY=",
|
||||
"enabledCodecs": [
|
||||
{"mime": "audio/opus"},
|
||||
{"mime": "video/H264"},
|
||||
{"mime": "video/VP8"},
|
||||
],
|
||||
},
|
||||
"id": "EV_eugWmGhovZmm",
|
||||
"createdAt": "1692985556",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def serialized_event_data(webhook_event_data):
|
||||
"""Serialize event data to JSON."""
|
||||
return json.dumps(webhook_event_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_livekit_config(settings):
|
||||
"""Mock LiveKit configuration."""
|
||||
settings.LIVEKIT_CONFIGURATION = {
|
||||
"api_key": "test_api_key",
|
||||
"api_secret": "test_api_secret",
|
||||
"url": "https://test-livekit.example.com/",
|
||||
}
|
||||
return settings.LIVEKIT_CONFIGURATION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_token(serialized_event_data, mock_livekit_config):
|
||||
"""Generate authentication token for webhook request."""
|
||||
hash64 = base64.b64encode(
|
||||
hashlib.sha256(serialized_event_data.encode()).digest()
|
||||
).decode()
|
||||
token = api.AccessToken(
|
||||
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
|
||||
)
|
||||
token.claims.sha256 = hash64
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
def test_missing_auth_header(client, serialized_event_data, mock_livekit_config):
|
||||
"""Should return 401 when auth header is missing."""
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=serialized_event_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"status": "error",
|
||||
"message": "Authorization header missing",
|
||||
}
|
||||
|
||||
|
||||
def test_invalid_payload(client, auth_token, mock_livekit_config):
|
||||
"""Should return 400 for invalid payload."""
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=json.dumps({"invalid": "payload"}),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
|
||||
|
||||
|
||||
def test_unknown_event_type(client, mock_livekit_config):
|
||||
"""Should return 422 for unknown event type."""
|
||||
event_data = json.dumps({"event": "unknown_event_type"})
|
||||
|
||||
# Generate auth token for this specific payload
|
||||
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
|
||||
token = api.AccessToken(
|
||||
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
|
||||
)
|
||||
token.claims.sha256 = hash64
|
||||
auth_token = token.to_jwt()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=event_data,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"status": "error",
|
||||
"message": "Unknown webhook type: unknown_event_type",
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||
def test_handled_event_type(
|
||||
mock_handler,
|
||||
client,
|
||||
serialized_event_data,
|
||||
auth_token,
|
||||
mock_livekit_config,
|
||||
):
|
||||
"""Should process valid webhook successfully."""
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=serialized_event_data,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
mock_handler.assert_called_once()
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
|
||||
|
||||
def test_unhandled_event_type(client, mock_livekit_config):
|
||||
"""Should return 200 for event types that have no handler."""
|
||||
event_data = json.dumps({"event": "room_started"})
|
||||
|
||||
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
|
||||
token = api.AccessToken(
|
||||
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
|
||||
)
|
||||
token.claims.sha256 = hash64
|
||||
auth_token = token.to_jwt()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=event_data,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
|
||||
|
||||
def test_action_error(client, mock_livekit_config):
|
||||
"""Should raise exceptions when errors occur during LiveKit webhook processing."""
|
||||
event_data = json.dumps(
|
||||
{
|
||||
"event": "room_finished",
|
||||
"room": {"sid": "RM_hycBMAjmt6Ub", "name": "invalid-uuid"},
|
||||
}
|
||||
)
|
||||
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
|
||||
token = api.AccessToken(
|
||||
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
|
||||
)
|
||||
token.claims.sha256 = hash64
|
||||
auth_token = token.to_jwt()
|
||||
|
||||
with pytest.raises(
|
||||
ActionFailedError,
|
||||
match="Failed to process room finished event",
|
||||
):
|
||||
client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=event_data,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.services.livekit_events import (
|
||||
ActionFailedError,
|
||||
AuthenticationError,
|
||||
InvalidPayloadError,
|
||||
LiveKitEventsService,
|
||||
UnsupportedEventTypeError,
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_livekit_config(settings):
|
||||
"""Mock LiveKit configuration."""
|
||||
settings.LIVEKIT_CONFIGURATION = {
|
||||
"api_key": "test_api_key",
|
||||
"api_secret": "test_api_secret",
|
||||
"url": "https://test-livekit.example.com/",
|
||||
}
|
||||
return settings.LIVEKIT_CONFIGURATION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(mock_livekit_config):
|
||||
"""Initialize LiveKitEventsService."""
|
||||
return LiveKitEventsService()
|
||||
|
||||
|
||||
@mock.patch("livekit.api.TokenVerifier")
|
||||
@mock.patch("livekit.api.WebhookReceiver")
|
||||
def test_initialization(
|
||||
mock_webhook_receiver, mock_token_verifier, mock_livekit_config
|
||||
):
|
||||
"""Should correctly initialize the service with required dependencies."""
|
||||
|
||||
api_key = mock_livekit_config["api_key"]
|
||||
api_secret = mock_livekit_config["api_secret"]
|
||||
|
||||
service = LiveKitEventsService()
|
||||
|
||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||
assert isinstance(service.lobby_service, LobbyService)
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
def test_handle_room_finished(mock_clear_cache, service):
|
||||
"""Should clear lobby cache when room is finished."""
|
||||
|
||||
mock_room_name = uuid.uuid4()
|
||||
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
||||
)
|
||||
def test_handle_room_finished_error(mock_clear_cache, service):
|
||||
"""Should raise ActionFailedError when processing fails."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||
with pytest.raises(
|
||||
ActionFailedError, match="Failed to process room finished event"
|
||||
):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
|
||||
def test_handle_room_finished_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when processing fails."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "invalid"
|
||||
with pytest.raises(
|
||||
ActionFailedError, match="Failed to process room finished event"
|
||||
):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
|
||||
)
|
||||
def test_receive_invalid_payload(mock_receive, service):
|
||||
"""Should raise InvalidPayloadError for invalid payloads."""
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
with pytest.raises(InvalidPayloadError, match="Invalid webhook payload"):
|
||||
service.receive(mock_request)
|
||||
|
||||
|
||||
def test_receive_missing_auth(service):
|
||||
"""Should raise AuthenticationError when auth header is missing."""
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {}
|
||||
|
||||
with pytest.raises(AuthenticationError, match="Authorization header missing"):
|
||||
service.receive(mock_request)
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
def test_receive_unsupported_event(mock_receive, service):
|
||||
"""Should raise LiveKitWebhookError for unsupported events."""
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
# Mock returned data with unsupported event type
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.event = "unsupported_event"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
with pytest.raises(
|
||||
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
|
||||
):
|
||||
service.receive(mock_request)
|
||||
@@ -0,0 +1,893 @@
|
||||
"""
|
||||
Test lobby service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613, W0212, R0913
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
LobbyNotificationError,
|
||||
LobbyParticipant,
|
||||
LobbyParticipantNotFound,
|
||||
LobbyParticipantParsingError,
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lobby_service():
|
||||
"""Return a LobbyService instance."""
|
||||
return LobbyService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def participant_id():
|
||||
"""Return a string ID for test participant."""
|
||||
return "test-participant-id"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def username():
|
||||
"""Return a username for test participant."""
|
||||
return "test-username"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def participant_dict():
|
||||
"""Return a valid participant dictionary."""
|
||||
return {
|
||||
"status": "waiting",
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def participant_data():
|
||||
"""Return a valid LobbyParticipant instance."""
|
||||
return LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username="test-username",
|
||||
id="test-participant-id",
|
||||
color="#123456",
|
||||
)
|
||||
|
||||
|
||||
def test_lobby_participant_to_dict(participant_data):
|
||||
"""Test LobbyParticipant serialization to dict."""
|
||||
result = participant_data.to_dict()
|
||||
|
||||
assert result["status"] == "waiting"
|
||||
assert result["username"] == "test-username"
|
||||
assert result["id"] == "test-participant-id"
|
||||
assert result["color"] == "#123456"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_success(participant_dict):
|
||||
"""Test successful LobbyParticipant creation from dict."""
|
||||
participant = LobbyParticipant.from_dict(participant_dict)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == "test-username"
|
||||
assert participant.id == "test-participant-id"
|
||||
assert participant.color == "#123456"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_default_status():
|
||||
"""Test LobbyParticipant creation with missing status defaults to UNKNOWN."""
|
||||
data_without_status = {
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
participant = LobbyParticipant.from_dict(data_without_status)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.UNKNOWN
|
||||
assert participant.username == "test-username"
|
||||
assert participant.id == "test-participant-id"
|
||||
assert participant.color == "#123456"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_missing_fields():
|
||||
"""Test LobbyParticipant creation with missing fields."""
|
||||
invalid_data = {"username": "test-username"}
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||
LobbyParticipant.from_dict(invalid_data)
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_invalid_status():
|
||||
"""Test LobbyParticipant creation with invalid status."""
|
||||
invalid_data = {
|
||||
"status": "invalid_status",
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||
LobbyParticipant.from_dict(invalid_data)
|
||||
|
||||
|
||||
def test_get_cache_key(lobby_service, participant_id):
|
||||
"""Test cache key generation."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = lobby_service._get_cache_key(room.id, participant_id)
|
||||
|
||||
expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
|
||||
assert cache_key == expected_key
|
||||
|
||||
|
||||
def test_get_or_create_participant_id_from_cookie(lobby_service):
|
||||
"""Test extracting participant ID from cookie."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
|
||||
|
||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
||||
|
||||
assert participant_id == "existing-id"
|
||||
|
||||
|
||||
@mock.patch("uuid.uuid4")
|
||||
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
|
||||
"""Test creating new participant ID when cookie is missing."""
|
||||
mock_uuid4.return_value = mock.Mock(hex="generated-id")
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {}
|
||||
|
||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
||||
|
||||
assert participant_id == "generated-id"
|
||||
mock_uuid4.assert_called_once()
|
||||
|
||||
|
||||
def test_prepare_response_existing_cookie(lobby_service, participant_id):
|
||||
"""Test response preparation with existing cookie."""
|
||||
response = HttpResponse()
|
||||
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
|
||||
|
||||
lobby_service.prepare_response(response, participant_id)
|
||||
|
||||
# Verify cookie wasn't set again
|
||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
||||
assert cookie.value == "existing-cookie"
|
||||
assert cookie.value != participant_id
|
||||
|
||||
|
||||
def test_prepare_response_new_cookie(lobby_service, participant_id):
|
||||
"""Test response preparation with new cookie."""
|
||||
response = HttpResponse()
|
||||
|
||||
lobby_service.prepare_response(response, participant_id)
|
||||
|
||||
# Verify cookie was set
|
||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
||||
assert cookie is not None
|
||||
assert cookie.value == participant_id
|
||||
assert cookie["httponly"] is True
|
||||
assert cookie["secure"] is True
|
||||
assert cookie["samesite"] == "Lax"
|
||||
|
||||
# It's a session cookies (no max_age specified):
|
||||
assert not cookie["max-age"]
|
||||
|
||||
|
||||
def test_can_bypass_lobby_public_room(lobby_service):
|
||||
"""Should return True for public rooms regardless of user auth."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
"""Should return True for trusted rooms with authenticated users."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
# Authenticated user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
"""Should return False for trusted rooms with anonymous users."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
|
||||
|
||||
def test_can_bypass_lobby_private_room(lobby_service):
|
||||
"""Should return False for private rooms regardless of user auth."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_public_room(
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry to a public room."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.UNKNOWN,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_trusted_room(
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user.is_authenticated = True
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.UNKNOWN,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService.enter")
|
||||
def test_request_entry_new_participant(
|
||||
mock_enter, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for a new participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=None)
|
||||
|
||||
participant_data = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
mock_enter.return_value = participant_data
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant == participant_data
|
||||
assert livekit_config is None
|
||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
|
||||
def test_request_entry_waiting_participant(
|
||||
mock_refresh, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for a waiting participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert livekit_config is None
|
||||
mock_refresh.assert_called_once_with(room.id, participant_id)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_accepted_participant(
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for an accepted participant."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||
"""Test refreshing waiting status for a participant."""
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||
mock_cache.touch.assert_called_once_with(
|
||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
||||
def test_enter_success(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
):
|
||||
"""Test successful participant entry."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
participant = lobby_service.enter(room.id, participant_id, username)
|
||||
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
assert participant.id == participant_id
|
||||
assert participant.color == "#123456"
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
mock_notify.assert_called_once_with(room_id=room.id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
||||
def test_enter_with_notification_error(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
):
|
||||
"""Test participant entry with notification error."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
mock_notify.side_effect = LobbyNotificationError("Error notifying")
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
participant = lobby_service.enter(room.id, participant_id, username)
|
||||
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_get_participant_not_found(mock_cache, lobby_service, participant_id):
|
||||
"""Test getting a participant that doesn't exist."""
|
||||
mock_cache.get.return_value = None
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
result = lobby_service._get_participant(room.id, participant_id)
|
||||
|
||||
assert result is None
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
mock_cache.get.assert_called_once_with("mocked_cache_key")
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.services.lobby.LobbyParticipant.from_dict")
|
||||
def test_get_participant_parsing_error(
|
||||
mock_from_dict, mock_cache, lobby_service, participant_id
|
||||
):
|
||||
"""Test handling corrupted participant data."""
|
||||
mock_cache.get.return_value = {"some": "data"}
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
mock_from_dict.side_effect = LobbyParticipantParsingError("Invalid data")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
result = lobby_service._get_participant(room.id, participant_id)
|
||||
|
||||
assert result is None
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
mock_cache.delete.assert_called_once_with("mocked_cache_key")
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants when none exist."""
|
||||
mock_cache.keys.return_value = []
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == []
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.get_many.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
"""Test listing waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.keys.return_value = [cache_key]
|
||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["status"] == "waiting"
|
||||
assert result[0]["username"] == "test-username"
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"""Test listing multiple waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
participant1 = {
|
||||
"status": "waiting",
|
||||
"username": "user1",
|
||||
"id": "participant1",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
participant2 = {
|
||||
"status": "waiting",
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
# Verify both participants are in the result
|
||||
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
|
||||
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
|
||||
|
||||
# Verify all participants have waiting status
|
||||
assert all(p["status"] == "waiting" for p in result)
|
||||
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with corrupted data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.keys.return_value = [cache_key]
|
||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_cache.delete.assert_called_once_with(cache_key)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with one valid and one corrupted entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
valid_participant = {
|
||||
"status": "waiting",
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
}
|
||||
|
||||
corrupted_participant = {"invalid": "data"}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: corrupted_participant,
|
||||
cache_key2: valid_participant,
|
||||
}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
# Check that only the valid participant is returned
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "participant2"
|
||||
assert result[0]["status"] == "waiting"
|
||||
assert result[0]["username"] == "user2"
|
||||
|
||||
# Verify corrupted entry was deleted
|
||||
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||
|
||||
# Verify both cache keys were queried
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||
"""Test listing only waiting participants (not accepted/denied)."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
participant1 = {
|
||||
"status": "waiting",
|
||||
"username": "user1",
|
||||
"id": "participant1",
|
||||
"color": "#123456",
|
||||
}
|
||||
participant2 = {
|
||||
"status": "accepted",
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "participant1"
|
||||
assert result[0]["status"] == "waiting"
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService._update_participant_status")
|
||||
def test_handle_participant_entry_allow(mock_update, lobby_service, participant_id):
|
||||
"""Test handling allowed participant entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service.handle_participant_entry(room.id, participant_id, allow_entry=True)
|
||||
|
||||
mock_update.assert_called_once_with(
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService._update_participant_status")
|
||||
def test_handle_participant_entry_deny(mock_update, lobby_service, participant_id):
|
||||
"""Test handling denied participant entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service.handle_participant_entry(room.id, participant_id, allow_entry=False)
|
||||
|
||||
mock_update.assert_called_once_with(
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.DENIED,
|
||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_update_participant_status_not_found(mock_cache, lobby_service, participant_id):
|
||||
"""Test updating status for non-existent participant."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
mock_cache.get.return_value = None
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
with pytest.raises(LobbyParticipantNotFound, match="Participant not found"):
|
||||
lobby_service._update_participant_status(
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
mock_cache.get.assert_called_once_with("mocked_cache_key")
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.services.lobby.LobbyParticipant.from_dict")
|
||||
def test_update_participant_status_corrupted_data(
|
||||
mock_from_dict, mock_cache, lobby_service, participant_id
|
||||
):
|
||||
"""Test updating status with corrupted participant data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
mock_cache.get.return_value = {"some": "data"}
|
||||
mock_from_dict.side_effect = LobbyParticipantParsingError("Invalid data")
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError):
|
||||
lobby_service._update_participant_status(
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
mock_cache.delete.assert_called_once_with("mocked_cache_key")
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_update_participant_status_success(mock_cache, lobby_service, participant_id):
|
||||
"""Test successful participant status update."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
participant_dict = {
|
||||
"status": "waiting",
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
mock_cache.get.return_value = participant_dict
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
lobby_service._update_participant_status(
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
expected_data = {
|
||||
"status": "accepted",
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
}
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key", expected_data, timeout=60
|
||||
)
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
"""Test successful participant notification."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Call the function
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify the send_data method was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
|
||||
assert send_data_request.room == str(room.id)
|
||||
assert (
|
||||
json.loads(send_data_request.data.decode("utf-8"))["type"]
|
||||
== settings.LOBBY_NOTIFICATION_TYPE
|
||||
)
|
||||
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
|
||||
|
||||
# Verify aclose was called
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
"""Test participant notification with API error."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="test error", code=123)
|
||||
)
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Call the function and expect an exception
|
||||
with pytest.raises(
|
||||
LobbyNotificationError, match="Failed to notify room participants"
|
||||
):
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify send_data was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
|
||||
# Verify aclose was still called after the exception
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_clear_room_cache(settings, lobby_service):
|
||||
"""Test clearing room cache actually removes entries from cache."""
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "test-lobby"
|
||||
settings.LOBBY_WAITING_TIMEOUT = 10000
|
||||
settings.LOBBY_ACCEPTED_TIMEOUT = 10000
|
||||
settings.LOBBY_DENIED_TIMEOUT = 10000
|
||||
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
cache.set(
|
||||
f"test-lobby_{room_id!s}_participant1",
|
||||
LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username="participant1",
|
||||
id="participant1",
|
||||
color="#123456",
|
||||
),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
cache.set(
|
||||
f"test-lobby_{room_id!s}_participant2",
|
||||
LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username="participant2",
|
||||
id="participant2",
|
||||
color="#123456",
|
||||
),
|
||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
)
|
||||
cache.set(
|
||||
f"test-lobby_{room_id!s}_participant3",
|
||||
LobbyParticipant(
|
||||
status=LobbyParticipantStatus.DENIED,
|
||||
username="participant3",
|
||||
id="participant3",
|
||||
color="#123456",
|
||||
),
|
||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||
)
|
||||
|
||||
lobby_service.clear_room_cache(room_id)
|
||||
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
|
||||
|
||||
def test_clear_room_empty(settings, lobby_service):
|
||||
"""Test clearing room cache when it's already empty."""
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "test-lobby"
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
lobby_service.clear_room_cache(room_id)
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
+4
-8
@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
import brevo_python
|
||||
import pytest
|
||||
|
||||
from core.services.marketing_service import (
|
||||
from core.services.marketing import (
|
||||
BrevoMarketingService,
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
@@ -144,9 +144,7 @@ def clear_marketing_cache():
|
||||
def test_get_marketing_service_caching(clear_marketing_cache):
|
||||
"""Test marketing service caching behavior."""
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = (
|
||||
"core.services.marketing_service.BrevoMarketingService"
|
||||
)
|
||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
@@ -163,14 +161,12 @@ def test_get_marketing_service_invalid_class(clear_marketing_cache):
|
||||
get_marketing_service()
|
||||
|
||||
|
||||
@mock.patch("core.services.marketing_service.import_string")
|
||||
@mock.patch("core.services.marketing.import_string")
|
||||
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
|
||||
"""Test service class is instantiated only once."""
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = (
|
||||
"core.services.marketing_service.BrevoMarketingService"
|
||||
)
|
||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
mock_service_cls = mock.Mock()
|
||||
@@ -16,7 +16,7 @@ from ..factories import (
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from ..models import ResourceAccess, RoleChoices
|
||||
from ..models import ResourceAccess, RoleChoices, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -44,13 +44,13 @@ def test_api_room_user_accesses_list_authenticated_not_related():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True)
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
UserResourceAccessFactory(resource=public_room)
|
||||
UserResourceAccessFactory(resource=public_room, role="member")
|
||||
UserResourceAccessFactory(resource=public_room, role="administrator")
|
||||
UserResourceAccessFactory(resource=public_room, role="owner")
|
||||
|
||||
private_room = RoomFactory(is_public=False)
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
UserResourceAccessFactory(resource=private_room)
|
||||
UserResourceAccessFactory(resource=private_room, role="member")
|
||||
UserResourceAccessFactory(resource=private_room, role="administrator")
|
||||
@@ -73,13 +73,17 @@ def test_api_room_user_accesses_list_authenticated_member():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True, users=[(user, "member")])
|
||||
public_room = RoomFactory(
|
||||
access_level=RoomAccessLevel.PUBLIC, users=[(user, "member")]
|
||||
)
|
||||
UserResourceAccessFactory(resource=public_room)
|
||||
UserResourceAccessFactory(resource=public_room, role="member")
|
||||
UserResourceAccessFactory(resource=public_room, role="administrator")
|
||||
UserResourceAccessFactory(resource=public_room, role="owner")
|
||||
|
||||
private_room = RoomFactory(is_public=False, users=[(user, "member")])
|
||||
private_room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, users=[(user, "member")]
|
||||
)
|
||||
UserResourceAccessFactory(resource=private_room)
|
||||
UserResourceAccessFactory(resource=private_room, role="member")
|
||||
UserResourceAccessFactory(resource=private_room, role="administrator")
|
||||
@@ -102,7 +106,7 @@ def test_api_room_user_accesses_list_authenticated_administrator():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True)
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
public_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(
|
||||
@@ -115,7 +119,7 @@ def test_api_room_user_accesses_list_authenticated_administrator():
|
||||
UserResourceAccessFactory(resource=public_room, role="owner"),
|
||||
)
|
||||
|
||||
private_room = RoomFactory(is_public=False)
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
private_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(
|
||||
@@ -148,7 +152,7 @@ def test_api_room_user_accesses_list_authenticated_owner():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True)
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
public_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(resource=public_room, user=user, role="owner"),
|
||||
@@ -158,7 +162,7 @@ def test_api_room_user_accesses_list_authenticated_owner():
|
||||
UserResourceAccessFactory(resource=public_room, role="administrator"),
|
||||
UserResourceAccessFactory(resource=public_room, role="owner"),
|
||||
)
|
||||
private_room = RoomFactory(is_public=False)
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
private_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(resource=private_room, user=user, role="owner"),
|
||||
@@ -252,8 +256,8 @@ def test_api_room_user_accesses_retrieve_authenticated_not_related():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
room = RoomFactory(is_public=is_public)
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(access_level=access_level)
|
||||
assert len(RoleChoices.choices) == 3
|
||||
|
||||
for role, _name in RoleChoices.choices:
|
||||
@@ -277,9 +281,9 @@ def test_api_room_user_accesses_retrieve_authenticated_member():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(
|
||||
is_public=is_public,
|
||||
access_level=access_level,
|
||||
users=[(user, "member")],
|
||||
)
|
||||
assert len(RoleChoices.choices) == 3
|
||||
@@ -305,8 +309,8 @@ def test_api_room_user_accesses_retrieve_authenticated_administrator():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
room = RoomFactory(is_public=is_public, users=[(user, "administrator")])
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(access_level=access_level, users=[(user, "administrator")])
|
||||
assert len(RoleChoices.choices) == 3
|
||||
|
||||
for role, _name in RoleChoices.choices:
|
||||
@@ -334,8 +338,8 @@ def test_api_room_user_accesses_retrieve_authenticated_owner():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
room = RoomFactory(is_public=is_public, users=[(user, "owner")])
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(access_level=access_level, users=[(user, "owner")])
|
||||
assert len(RoleChoices.choices) == 3
|
||||
|
||||
for role, _name in RoleChoices.choices:
|
||||
|
||||
@@ -22,10 +22,32 @@ def test_api_users_list_anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_list_authenticated():
|
||||
def test_api_users_list_authenticated_secure(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users.
|
||||
Authenticated users should not be able to list any user
|
||||
when ALLOW_UNSECURE_USER_LISTING is False.
|
||||
"""
|
||||
settings.ALLOW_UNSECURE_USER_LISTING = False
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 0
|
||||
|
||||
|
||||
def test_api_users_list_authenticated_unsecure(settings):
|
||||
"""
|
||||
Authenticated users should be able to list all users
|
||||
when ALLOW_UNSECURE_USER_LISTING is True.
|
||||
"""
|
||||
settings.ALLOW_UNSECURE_USER_LISTING = True
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -40,11 +62,12 @@ def test_api_users_list_authenticated():
|
||||
assert len(content["results"]) == 3
|
||||
|
||||
|
||||
def test_api_users_list_query_email():
|
||||
def test_api_users_list_query_email(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users
|
||||
and filter by email.
|
||||
"""
|
||||
settings.ALLOW_UNSECURE_USER_LISTING = True
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
|
||||
import pytest
|
||||
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
from core.models import Room
|
||||
from core.models import Room, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -80,10 +80,10 @@ def test_models_rooms_users():
|
||||
assert list(room.users.all()) == [user]
|
||||
|
||||
|
||||
def test_models_rooms_is_public_default():
|
||||
def test_models_rooms_access_level_default():
|
||||
"""A room should be public by default."""
|
||||
room = Room.objects.create(name="room")
|
||||
assert room.is_public is True
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
# Access rights methods
|
||||
@@ -164,3 +164,14 @@ def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
|
||||
assert room.is_administrator(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is True
|
||||
|
||||
|
||||
def test_models_rooms_is_public_property():
|
||||
"""Test the is_public property returns correctly based on access_level."""
|
||||
# Test public room
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
assert public_room.is_public is True
|
||||
|
||||
# Test non-public room
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
assert private_room.is_public is False
|
||||
|
||||
@@ -37,7 +37,9 @@ def generate_color(identity: str) -> str:
|
||||
return f"hsl({hue}, {saturation}%, {lightness}%)"
|
||||
|
||||
|
||||
def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
def generate_token(
|
||||
room: str, user, username: Optional[str] = None, color: Optional[str] = None
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
Args:
|
||||
@@ -45,6 +47,8 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
user (User): The user which request the access token.
|
||||
username (Optional[str]): The username to be displayed in the room.
|
||||
If none, a default value will be used.
|
||||
color (Optional[str]): The color to be displayed in the room.
|
||||
If none, a value will be generated
|
||||
|
||||
Returns:
|
||||
str: The LiveKit JWT access token.
|
||||
@@ -69,6 +73,9 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
identity = str(user.sub)
|
||||
default_username = str(user)
|
||||
|
||||
if color is None:
|
||||
color = generate_color(identity)
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
@@ -77,7 +84,29 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_metadata(json.dumps({"color": generate_color(identity)}))
|
||||
.with_metadata(json.dumps({"color": color}))
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
def generate_livekit_config(
|
||||
room_id: str, user, username: str, color: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
Args:
|
||||
room_id: Room identifier
|
||||
user: User instance requesting access
|
||||
username: Display name in room
|
||||
|
||||
Returns:
|
||||
dict: LiveKit configuration with URL, room and access token
|
||||
"""
|
||||
return {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": room_id,
|
||||
"token": generate_token(
|
||||
room=room_id, user=user, username=username, color=color
|
||||
),
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ class Base(Configuration):
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = values.Value(None)
|
||||
SILENCED_SYSTEM_CHECKS = values.ListValue([])
|
||||
ALLOW_UNSECURE_USER_LISTING = values.BooleanValue(
|
||||
False, environ_name="ALLOW_UNSECURE_USER_LISTING", environ_prefix=None
|
||||
)
|
||||
|
||||
# Application definition
|
||||
ROOT_URLCONF = "meet.urls"
|
||||
@@ -235,7 +238,17 @@ class Base(Configuration):
|
||||
|
||||
# Cache
|
||||
CACHES = {
|
||||
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/1",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
@@ -252,6 +265,13 @@ class Base(Configuration):
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"request_entry": values.Value(
|
||||
default="150/minute",
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
@@ -294,7 +314,6 @@ class Base(Configuration):
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
@@ -411,8 +430,8 @@ class Base(Configuration):
|
||||
),
|
||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||
}
|
||||
RESOURCE_DEFAULT_IS_PUBLIC = values.BooleanValue(
|
||||
True, environ_name="RESOURCE_DEFAULT_IS_PUBLIC", environ_prefix=None
|
||||
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
|
||||
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
|
||||
)
|
||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||
@@ -464,7 +483,7 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
MARKETING_SERVICE_CLASS = values.Value(
|
||||
"core.services.marketing_service.BrevoMarketingService",
|
||||
"core.services.marketing.BrevoMarketingService",
|
||||
environ_name="MARKETING_SERVICE_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
@@ -479,6 +498,32 @@ class Base(Configuration):
|
||||
)
|
||||
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
|
||||
|
||||
# Lobby configurations
|
||||
LOBBY_KEY_PREFIX = values.Value(
|
||||
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
||||
)
|
||||
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
|
||||
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
|
||||
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
LOBBY_ACCEPTED_TIMEOUT = values.PositiveIntegerValue(
|
||||
21600, # 6hrs
|
||||
environ_name="LOBBY_ACCEPTED_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
LOBBY_NOTIFICATION_TYPE = values.Value(
|
||||
"participantWaiting",
|
||||
environ_name="LOBBY_NOTIFICATION_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
LOBBY_COOKIE_NAME = values.Value(
|
||||
"lobbyParticipantId",
|
||||
environ_name="LOBBY_COOKIE_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
|
||||
+18
-19
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.12"
|
||||
version = "0.1.15"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,40 +25,39 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.36.6",
|
||||
"boto3==1.37.4",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-storages[s3]==1.14.5",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.5",
|
||||
"django==5.1.7",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.23.0",
|
||||
"june-analytics-python==2.3.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.4",
|
||||
"psycopg[binary]==3.2.5",
|
||||
"PyJWT==2.10.1",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.20.0",
|
||||
"sentry-sdk==2.22.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.8.2",
|
||||
"whitenoise==6.9.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.8.1",
|
||||
"aiohttp==3.11.11",
|
||||
"livekit-api==0.8.2",
|
||||
"aiohttp==3.11.13",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -70,21 +69,21 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"drf-spectacular-sidecar==2025.3.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.31.0",
|
||||
"ipython==9.0.1",
|
||||
"pyfakefs==5.7.4",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.3",
|
||||
"pylint==3.3.4",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.4",
|
||||
"pytest-django==4.10.0",
|
||||
"pytest==8.3.5",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.6",
|
||||
"ruff==0.9.3",
|
||||
"types-requests==2.32.0.20241016",
|
||||
"ruff==0.9.9",
|
||||
"types-requests==2.32.0.20250301",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -29,11 +29,19 @@ FROM meet AS meet-builder
|
||||
|
||||
WORKDIR /home/frontend
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
|
||||
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
|
||||
"output": "src/locales/$LOCALE/$NAMESPACE.json",
|
||||
"createOldCatalogs": false,
|
||||
"locales": ["en", "fr", "de"],
|
||||
"locales": ["en", "fr", "de", "nl"],
|
||||
"sort": true
|
||||
}
|
||||
|
||||
Generated
+660
-617
File diff suppressed because it is too large
Load Diff
+22
-22
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -16,45 +16,45 @@
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.3",
|
||||
"@pandacss/preset-panda": "0.51.1",
|
||||
"@pandacss/preset-panda": "0.53.0",
|
||||
"@react-aria/toast": "3.0.0-beta.19",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.64.2",
|
||||
"@tanstack/react-query": "5.67.1",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "24.2.2",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"i18next-parser": "9.1.0",
|
||||
"i18next-browser-languagedetector": "8.0.4",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.8.1",
|
||||
"posthog-js": "1.210.2",
|
||||
"livekit-client": "2.9.5",
|
||||
"posthog-js": "1.225.1",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "4.0.3",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.1.3",
|
||||
"wouter": "3.4.0"
|
||||
"wouter": "3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.51.1",
|
||||
"@tanstack/eslint-plugin-query": "5.64.2",
|
||||
"@tanstack/react-query-devtools": "5.64.2",
|
||||
"@types/node": "22.10.10",
|
||||
"@pandacss/dev": "0.53.0",
|
||||
"@tanstack/eslint-plugin-query": "5.66.1",
|
||||
"@tanstack/react-query-devtools": "5.67.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.21.0",
|
||||
"@typescript-eslint/parser": "8.21.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.26.0",
|
||||
"@typescript-eslint/parser": "8.26.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "10.0.1",
|
||||
"eslint-config-prettier": "10.0.2",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.1.0",
|
||||
"eslint-plugin-react-refresh": "0.4.18",
|
||||
"postcss": "8.5.1",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.0.11",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.19",
|
||||
"postcss": "8.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.0",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,32 @@ const config: Config = {
|
||||
'50%': { opacity: '0.65' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
rotate: {
|
||||
'0%': {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'rotate(360deg)',
|
||||
},
|
||||
},
|
||||
prixClipFix: {
|
||||
'0%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0)',
|
||||
},
|
||||
'25%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0)',
|
||||
},
|
||||
'50%': {
|
||||
clipPath:
|
||||
'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%)',
|
||||
},
|
||||
'75%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%)',
|
||||
},
|
||||
'100%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
@@ -197,6 +223,7 @@ const config: Config = {
|
||||
fonts: {
|
||||
sans: {
|
||||
value: [
|
||||
'Marianne',
|
||||
'Source Sans',
|
||||
'Source Sans fallback',
|
||||
'ui-sans-serif',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from 'hoofd'
|
||||
import { Switch, Route } from 'wouter'
|
||||
import { Switch, Route, useLocation } from 'wouter'
|
||||
import { I18nProvider } from 'react-aria-components'
|
||||
import { Layout } from './layout/Layout'
|
||||
import { NotFoundScreen } from './components/NotFoundScreen'
|
||||
@@ -13,10 +13,35 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { SdkCreateButton } from './features/sdk/routes/CreateButton'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
const [location] = useLocation()
|
||||
const isSDKRoute = location.startsWith(SDK_BASE_ROUTE)
|
||||
|
||||
if (isSDKRoute) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Switch>
|
||||
<Route path={SDK_BASE_ROUTE} nest>
|
||||
<Route path="/create-button">
|
||||
<SdkCreateButton />
|
||||
</Route>
|
||||
</Route>
|
||||
</Switch>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppInitialization />
|
||||
|
||||
@@ -2,4 +2,6 @@ export const keys = {
|
||||
user: 'user',
|
||||
room: 'room',
|
||||
config: 'config',
|
||||
requestEntry: 'requestEntry',
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export const VisioIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0.841702 8.95427C0.75 9.42479 0.75 10.0042 0.75 10.9748V13.3096C0.75 14.5138 0.75 15.1159 0.925134 15.6549C1.08009 16.1319 1.33356 16.5709 1.6691 16.9435C2.04833 17.3647 2.56977 17.6658 3.61264 18.2679L5.6346 19.4353C6.55753 19.9681 7.07208 20.2652 7.56247 20.4081V13.2043C7.56247 12.8442 7.36578 12.5129 7.04965 12.3404L0.841702 8.95427Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17.747 10.0123V13.7685C17.747 14.2878 17.9727 14.7815 18.3654 15.1214L21.0688 17.4609C21.227 17.5947 21.3912 17.7042 21.5616 17.7894C21.738 17.8685 21.9084 17.9081 22.0726 17.9081C22.4255 17.9081 22.7084 17.7925 22.9213 17.5613C23.1404 17.324 23.2499 17.0168 23.2499 16.6396V7.14866C23.2499 6.77146 23.1404 6.46726 22.9213 6.23607C22.7084 5.9988 22.4255 5.88017 22.0726 5.88017C21.9084 5.88017 21.738 5.91971 21.5616 5.9988C21.3912 6.07789 21.227 6.1874 21.0688 6.32733L18.3675 8.65759C17.9735 8.99746 17.747 9.49201 17.747 10.0123Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M1.74517 7.61282C1.67 7.57182 1.59117 7.54405 1.51135 7.52872C1.56171 7.46443 1.61433 7.40178 1.66914 7.34091C2.04837 6.91973 2.56981 6.61868 3.61268 6.01657L5.63464 4.84919C6.67751 4.24708 7.19894 3.94603 7.7533 3.82819C8.2438 3.72394 8.75074 3.72394 9.24124 3.82819C9.7956 3.94603 10.317 4.24708 11.3599 4.84919L13.358 6.0028C13.366 6.00738 13.374 6.01197 13.3819 6.01658C14.4248 6.61868 14.9462 6.91973 15.3255 7.34091C15.661 7.71357 15.9145 8.15259 16.0694 8.62951C16.2446 9.16852 16.2446 9.77063 16.2446 10.9748V13.3096C16.2446 14.5138 16.2446 15.1159 16.0694 15.6549C15.9145 16.1319 15.661 16.5709 15.3255 16.9435C14.9462 17.3647 14.4248 17.6657 13.382 18.2678C13.373 18.273 13.364 18.2782 13.3551 18.2833L11.3599 19.4353C10.317 20.0374 9.7956 20.3384 9.24124 20.4562C9.21846 20.4611 9.19564 20.4657 9.17279 20.4701L9.17278 13.2043C9.17278 12.255 8.65422 11.3814 7.82079 10.9268L1.74517 7.61282Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cva, RecipeVariantProps } from '@/styled-system/css'
|
||||
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
||||
import React from 'react'
|
||||
|
||||
const avatar = cva({
|
||||
@@ -19,13 +19,19 @@ const avatar = cva({
|
||||
list: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '1.25rem',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
true: {
|
||||
border: '2px solid white',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
context: 'list',
|
||||
@@ -37,17 +43,31 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
bgColor?: string
|
||||
} & RecipeVariantProps<typeof avatar>
|
||||
|
||||
export const Avatar = ({ name, bgColor, context, ...props }: AvatarProps) => {
|
||||
export const Avatar = ({
|
||||
name,
|
||||
bgColor,
|
||||
context,
|
||||
notification,
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) || ''
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
}}
|
||||
className={avatar({ context })}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
{initial}
|
||||
<span
|
||||
className={css({
|
||||
marginTop: '-0.3rem',
|
||||
})}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,10 @@ export const FeedbackBanner = () => {
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={20} />
|
||||
<Text as="p">{t('feedback.context')}</Text>
|
||||
<RiErrorWarningLine size={20} aria-hidden="true" />
|
||||
<Text as="p" variant="sm">
|
||||
{t('feedback.context')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -33,7 +35,7 @@ export const FeedbackBanner = () => {
|
||||
gap: 0.25,
|
||||
})}
|
||||
>
|
||||
<A href={GRIST_FORM} target="_blank">
|
||||
<A href={GRIST_FORM} target="_blank" size="sm">
|
||||
{t('feedback.cta')}
|
||||
</A>
|
||||
<RiExternalLinkLine size={16} aria-hidden="true" />
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Text } from '@/primitives/Text'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Bold } from '@/primitives'
|
||||
|
||||
export const NotFoundScreen = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Screen layout="centered">
|
||||
<CenteredContent title={t('notFound.heading')} withBackButton />
|
||||
<CenteredContent title={t('notFound.heading')} withBackButton>
|
||||
<Text centered>
|
||||
{t('notFound.body')}{' '}
|
||||
<Bold>https://visio.numerique.gouv.fr/xxx-yyyy-zzz.</Bold>
|
||||
</Text>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,13 @@ import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
||||
* Here our wrapper just returns false in that case, without triggering an error:
|
||||
* this is done to prevent unnecessary query retries with react query
|
||||
*/
|
||||
export const fetchUser = (): Promise<ApiUser | false> => {
|
||||
export const fetchUser = (
|
||||
opts: {
|
||||
attemptSilent?: boolean
|
||||
} = {
|
||||
attemptSilent: true,
|
||||
}
|
||||
): Promise<ApiUser | false> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetchApi<ApiUser>('/users/me')
|
||||
.then(resolve)
|
||||
@@ -19,7 +25,7 @@ export const fetchUser = (): Promise<ApiUser | false> => {
|
||||
if (error instanceof ApiError && error.statusCode === 401) {
|
||||
// make sure to not resolve the promise while trying to silent login
|
||||
// so that consumers of fetchUser don't think the work already ended
|
||||
if (canAttemptSilentLogin()) {
|
||||
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
||||
attemptSilentLogin(300)
|
||||
} else {
|
||||
resolve(false)
|
||||
|
||||
@@ -3,18 +3,30 @@ import { keys } from '@/api/queryKeys'
|
||||
import { fetchUser } from './fetchUser'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
import { useEffect } from 'react'
|
||||
import { startAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { initializeSupportSession } from '@/features/support/hooks/useSupport'
|
||||
import {
|
||||
startAnalyticsSession,
|
||||
terminateAnalyticsSession,
|
||||
} from '@/features/analytics/hooks/useAnalytics'
|
||||
import {
|
||||
initializeSupportSession,
|
||||
terminateSupportSession,
|
||||
} from '@/features/support/hooks/useSupport'
|
||||
import { logoutUrl } from '../utils/logoutUrl'
|
||||
|
||||
/**
|
||||
* returns info about currently logged-in user
|
||||
*
|
||||
* `isLoggedIn` is undefined while query is loading and true/false when it's done
|
||||
*/
|
||||
export const useUser = () => {
|
||||
export const useUser = (
|
||||
opts: {
|
||||
fetchUserOptions?: Parameters<typeof fetchUser>[0]
|
||||
} = {}
|
||||
) => {
|
||||
const query = useQuery({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
queryKey: [keys.user],
|
||||
queryFn: fetchUser,
|
||||
queryFn: () => fetchUser(opts.fetchUserOptions),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
@@ -25,6 +37,12 @@ export const useUser = () => {
|
||||
}
|
||||
}, [query.data])
|
||||
|
||||
const logout = () => {
|
||||
terminateAnalyticsSession()
|
||||
terminateSupportSession()
|
||||
window.location.href = logoutUrl()
|
||||
}
|
||||
|
||||
const isLoggedIn =
|
||||
query.status === 'success' ? query.data !== false : undefined
|
||||
const isLoggedOut = isLoggedIn === false
|
||||
@@ -33,5 +51,6 @@ export const useUser = () => {
|
||||
...query,
|
||||
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
|
||||
isLoggedIn,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { useUser } from './api/useUser'
|
||||
export { authUrl } from './utils/authUrl'
|
||||
export { logoutUrl } from './utils/logoutUrl'
|
||||
export { UserAware } from './components/UserAware'
|
||||
|
||||
@@ -17,12 +17,13 @@ const Heading = styled('h2', {
|
||||
base: {
|
||||
width: 'fit-content',
|
||||
marginBottom: 0,
|
||||
fontSize: '1.5rem',
|
||||
fontSize: '1.3rem',
|
||||
fontWeight: '600',
|
||||
marginTop: '0.75rem',
|
||||
lineHeight: '2rem',
|
||||
maxWidth: '23rem',
|
||||
textAlign: 'center',
|
||||
textWrap: 'pretty',
|
||||
textWrap: 'balance',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -31,7 +32,7 @@ const Body = styled('p', {
|
||||
maxWidth: '23rem',
|
||||
textAlign: 'center',
|
||||
textWrap: 'pretty',
|
||||
lineHeight: '1.2rem',
|
||||
lineHeight: '1.4rem',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
})
|
||||
@@ -102,7 +103,7 @@ const Slide = styled('div', {
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'start',
|
||||
minHeight: { base: 'none', xsm: '550px' },
|
||||
minHeight: { base: 'none', xsm: '580px' },
|
||||
minWidth: { base: 'none', xsm: '200px' },
|
||||
width: { base: '100%', xsm: '22.625rem' },
|
||||
},
|
||||
|
||||
@@ -13,11 +13,12 @@ import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { SdkReverseClient } from '@/features/sdk/SdkReverseClient'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -71,8 +72,8 @@ const LeftColumn = ({ children }: { children?: ReactNode }) => {
|
||||
textAlign: 'left',
|
||||
alignItems: 'flex-start',
|
||||
flexShrink: '1',
|
||||
flexBasis: '36rem',
|
||||
maxWidth: '36rem',
|
||||
flexBasis: '40rem',
|
||||
maxWidth: '40rem',
|
||||
padding: '1em 3em',
|
||||
},
|
||||
})}
|
||||
@@ -119,14 +120,13 @@ const Separator = styled('div', {
|
||||
|
||||
const Heading = styled('h1', {
|
||||
base: {
|
||||
fontWeight: '500',
|
||||
fontWeight: '600',
|
||||
fontStyle: 'normal',
|
||||
fontStretch: 'normal',
|
||||
fontOpticalSizing: 'auto',
|
||||
marginBottom: 0,
|
||||
paddingBottom: '0.75rem',
|
||||
paddingBottom: '1.2rem',
|
||||
fontSize: '2.3rem',
|
||||
lineHeight: '2.5rem',
|
||||
lineHeight: '2.6rem',
|
||||
letterSpacing: '0',
|
||||
xsm: {
|
||||
fontSize: '3rem',
|
||||
@@ -138,9 +138,9 @@ const Heading = styled('h1', {
|
||||
const IntroText = styled('div', {
|
||||
base: {
|
||||
marginBottom: '3rem',
|
||||
fontSize: '1.5rem',
|
||||
lineHeight: '1.8rem',
|
||||
textWrap: 'pretty',
|
||||
fontSize: '1.2rem',
|
||||
lineHeight: '1.5rem',
|
||||
textWrap: 'balance',
|
||||
maxWidth: '32rem',
|
||||
},
|
||||
})
|
||||
@@ -156,6 +156,18 @@ export const Home = () => {
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
||||
|
||||
const { user } = useUser()
|
||||
|
||||
/**
|
||||
* Used for SDK popup to close automatically.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
SdkReverseClient.broadcastAuthentication()
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { H, P, A, Italic, Ul } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const AccessibilityRoute = () => {
|
||||
const { t } = useTranslation('accessibility', { keyPrefix: 'accessibility' })
|
||||
|
||||
return (
|
||||
<Screen layout="centered" headerTitle={t('title')}>
|
||||
<HStack display={'block'} padding={'2rem'}>
|
||||
<P dangerouslySetInnerHTML={{ __html: t('introduction') }}></P>
|
||||
<H lvl={2} bold>
|
||||
{t('declaration.title')}
|
||||
</H>
|
||||
<Italic>{t('declaration.date')}</Italic>
|
||||
<P>
|
||||
{t('scope').replace('visio.numerique.gouv.fr', '')}{' '}
|
||||
<A href="https://visio.numerique.gouv.fr" color="primary">
|
||||
visio.numerique.gouv.fr
|
||||
</A>
|
||||
.
|
||||
</P>
|
||||
|
||||
<H lvl={2} bold>
|
||||
{t('complianceStatus.title')}
|
||||
</H>
|
||||
<P>
|
||||
<A href="https://visio.numerique.gouv.fr" color="primary">
|
||||
visio.numerique.gouv.fr
|
||||
</A>
|
||||
{t('complianceStatus.body').replace('visio.numerique.gouv.fr', '')}
|
||||
</P>
|
||||
|
||||
<H lvl={2} bold>
|
||||
{t('improvement.title')}
|
||||
</H>
|
||||
<P>{t('improvement.body')}</P>
|
||||
|
||||
<Ul
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<li>
|
||||
{t('improvement.contact.email').replace(
|
||||
'visio@numerique.gouv.fr',
|
||||
''
|
||||
)}
|
||||
<A href="mailto:lasuite@modernisation.gouv.fr" color="primary">
|
||||
lasuite@modernisation.gouv.fr
|
||||
</A>
|
||||
</li>
|
||||
<li>{t('improvement.contact.address')}</li>
|
||||
</Ul>
|
||||
|
||||
<P>{t('improvement.response')}</P>
|
||||
|
||||
<H lvl={2} bold>
|
||||
{t('recourse.title')}
|
||||
</H>
|
||||
<P>{t('recourse.introduction')}</P>
|
||||
|
||||
<P>{t('recourse.options.intro')}</P>
|
||||
|
||||
<Ul>
|
||||
<li>{t('recourse.options.option1')}</li>
|
||||
<li>{t('recourse.options.option2')}</li>
|
||||
<li>
|
||||
<P
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('recourse.options.option3'),
|
||||
}}
|
||||
></P>
|
||||
</li>
|
||||
</Ul>
|
||||
</HStack>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Bold, H, P, A, Link } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const LegalTermsRoute = () => {
|
||||
const { t } = useTranslation('legals')
|
||||
|
||||
const indentedStyle = css({
|
||||
paddingLeft: '1.5rem',
|
||||
marginLeft: '1rem',
|
||||
borderLeft: '1px solid black',
|
||||
marginTop: '1.5rem',
|
||||
})
|
||||
|
||||
return (
|
||||
<Screen layout="centered" headerTitle={t('title')}>
|
||||
<HStack display={'block'} padding={'2rem'}>
|
||||
{/* Éditeur section */}
|
||||
<H lvl={2}>{t('creator.title')}</H>
|
||||
<P>{t('creator.body')}</P>
|
||||
<P className={indentedStyle}>
|
||||
{t('creator.contact.address')}
|
||||
<br />
|
||||
{t('creator.contact.city')}
|
||||
<br />
|
||||
{t('creator.contact.phone')}
|
||||
<br />
|
||||
{t('creator.contact.siret')}
|
||||
<br />
|
||||
{t('creator.contact.siren')}
|
||||
</P>
|
||||
|
||||
{/* Directeur de la publication section */}
|
||||
<H lvl={2}>{t('director.title')}</H>
|
||||
<P>{t('director.body')}</P>
|
||||
|
||||
{/* Hébergement section */}
|
||||
<H lvl={2}>{t('hosting.title')}</H>
|
||||
<P>{t('hosting.body')}</P>
|
||||
|
||||
{/* Accessibilité section */}
|
||||
<H lvl={2}>{t('accessibility.title')}</H>
|
||||
<P>{t('accessibility.body')}</P>
|
||||
<P>
|
||||
{t('accessibility.more')}
|
||||
<Link to="/accessibilite" color="primary">
|
||||
{t('accessibility.link')}
|
||||
</Link>
|
||||
</P>
|
||||
|
||||
<P>
|
||||
<Bold>{t('accessibility.status')}</Bold>
|
||||
</P>
|
||||
|
||||
{/* Réutilisation des contenus et liens section */}
|
||||
<H lvl={2}>{t('reuse.title')}</H>
|
||||
<P>
|
||||
{t('reuse.body1')}{' '}
|
||||
<A
|
||||
href="https://www.etalab.gouv.fr/wp-content/uploads/2017/04/ETALAB-Licence-Ouverte-v2.0.pdf"
|
||||
color="primary"
|
||||
>
|
||||
{t('reuse.license')}
|
||||
</A>
|
||||
.
|
||||
</P>
|
||||
<P>{t('reuse.body2')}</P>
|
||||
<P>{t('reuse.body3')}</P>
|
||||
</HStack>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { H, P, A, Ul } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const ensureArray = (value: any) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
return []
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export const TermsOfServiceRoute = () => {
|
||||
const { t } = useTranslation('termsOfService')
|
||||
|
||||
return (
|
||||
<Screen layout="centered" headerTitle={t('title')}>
|
||||
<HStack display={'block'} padding={'2rem'}>
|
||||
{/* Article 1 */}
|
||||
<H lvl={2}>{t('articles.article1.title')}</H>
|
||||
<P>{t('articles.article1.content')}</P>
|
||||
|
||||
{/* Article 2 */}
|
||||
<H lvl={2}>{t('articles.article2.title')}</H>
|
||||
<P>{t('articles.article2.content')}</P>
|
||||
<P>{t('articles.article2.purposes')}</P>
|
||||
|
||||
{/* Article 3 */}
|
||||
<H lvl={2}>{t('articles.article3.title')}</H>
|
||||
<P>{t('articles.article3.definition')}</P>
|
||||
|
||||
{/* Article 4 */}
|
||||
<H lvl={2}>{t('articles.article4.title')}</H>
|
||||
<P>{t('articles.article4.content')}</P>
|
||||
|
||||
{/* Article 5 */}
|
||||
<H lvl={2} margin={false}>
|
||||
{t('articles.article5.title')}
|
||||
</H>
|
||||
|
||||
{/* Section 5.1 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article5.sections.section1.title')}
|
||||
</H>
|
||||
<P>{t('articles.article5.sections.section1.content')}</P>
|
||||
<P>{t('articles.article5.sections.section1.paragraph1')}</P>
|
||||
<P>{t('articles.article5.sections.section1.paragraph2')}</P>
|
||||
<Ul>
|
||||
{ensureArray(
|
||||
t('articles.article5.sections.section1.capabilities', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((capability, index) => (
|
||||
<li key={index}>{capability}</li>
|
||||
))}
|
||||
</Ul>
|
||||
<P
|
||||
style={{ marginTop: '0.75rem' }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('articles.article5.sections.section1.paragraph3'),
|
||||
}}
|
||||
></P>
|
||||
|
||||
{/* Section 5.2 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article5.sections.section2.title')}
|
||||
</H>
|
||||
<P>{t('articles.article5.sections.section2.content')}</P>
|
||||
<P style={{ marginTop: '0.75rem' }}>
|
||||
{t('articles.article5.sections.section2.paragraph')}
|
||||
</P>
|
||||
<Ul>
|
||||
{ensureArray(
|
||||
t('articles.article5.sections.section2.capabilities', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((capability, index) => (
|
||||
<li key={index}>{capability}</li>
|
||||
))}
|
||||
</Ul>
|
||||
|
||||
{/* Article 6 */}
|
||||
<H lvl={2} margin={false}>
|
||||
{t('articles.article6.title')}
|
||||
</H>
|
||||
|
||||
{/* Section 6.1 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article6.sections.section1.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('articles.article6.sections.section1.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Section 6.2 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article6.sections.section2.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('articles.article6.sections.section2.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Article 7 */}
|
||||
<H lvl={2} margin={false}>
|
||||
{t('articles.article7.title')}
|
||||
</H>
|
||||
|
||||
{/* Section 7.1 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section1.title')}
|
||||
</H>
|
||||
<P>{t('articles.article7.sections.section1.content')}</P>
|
||||
{ensureArray(
|
||||
t('articles.article7.sections.section1.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Section 7.2 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section2.title')}
|
||||
</H>
|
||||
<P>
|
||||
{t('articles.article7.sections.section2.content')
|
||||
.split('https://github.com/suitenumerique/meet')[0]
|
||||
.replace('https://github.com/suitenumerique/meet', '')}{' '}
|
||||
<A href="https://github.com/suitenumerique/meet" color="primary">
|
||||
https://github.com/suitenumerique/meet
|
||||
</A>
|
||||
{'. '}
|
||||
{
|
||||
t('articles.article7.sections.section2.content').split(
|
||||
'https://github.com/suitenumerique/meet'
|
||||
)[1]
|
||||
}
|
||||
</P>
|
||||
|
||||
{/* Article 8 */}
|
||||
<H lvl={2}>{t('articles.article8.title')}</H>
|
||||
<P>{t('articles.article8.content')}</P>
|
||||
</HStack>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,103 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RoomEvent } from 'livekit-client'
|
||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Div } from '@/primitives'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { NotificationDuration } from './NotificationDuration'
|
||||
import { decodeNotificationDataReceived } from './utils'
|
||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||
import {
|
||||
EMOJIS,
|
||||
Reaction,
|
||||
} from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||
import {
|
||||
ANIMATION_DURATION,
|
||||
ReactionPortals,
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
|
||||
const [reactions, setReactions] = useState<Reaction[]>([])
|
||||
const instanceIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const handleChatMessage = (
|
||||
chatMessage: ChatMessage,
|
||||
participant?: Participant | undefined
|
||||
) => {
|
||||
if (!participant || participant.isLocal) return
|
||||
triggerNotificationSound(NotificationType.MessageReceived)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant: participant,
|
||||
message: chatMessage.message,
|
||||
type: NotificationType.MessageReceived,
|
||||
},
|
||||
{ timeout: NotificationDuration.MESSAGE }
|
||||
)
|
||||
}
|
||||
room.on(RoomEvent.ChatMessage, handleChatMessage)
|
||||
return () => {
|
||||
room.off(RoomEvent.ChatMessage, handleChatMessage)
|
||||
}
|
||||
}, [room, triggerNotificationSound])
|
||||
|
||||
const handleEmoji = (emoji: string, participant: Participant) => {
|
||||
if (!emoji || !EMOJIS.includes(emoji)) return
|
||||
const id = instanceIdRef.current++
|
||||
setReactions((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
emoji,
|
||||
participant,
|
||||
},
|
||||
])
|
||||
setTimeout(() => {
|
||||
setReactions((prev) => prev.filter((instance) => instance.id !== id))
|
||||
}, ANIMATION_DURATION)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant
|
||||
) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
|
||||
if (!participant || !notification) return
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.ParticipantMuted:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.ParticipantMuted,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
)
|
||||
break
|
||||
case NotificationType.ReactionReceived:
|
||||
if (notification.data?.emoji)
|
||||
handleEmoji(notification.data.emoji, participant)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
useEffect(() => {
|
||||
const showJoinNotification = (participant: Participant) => {
|
||||
if (isMobileBrowser()) {
|
||||
@@ -23,7 +110,7 @@ export const MainNotificationToast = () => {
|
||||
type: NotificationType.ParticipantJoined,
|
||||
},
|
||||
{
|
||||
timeout: 5000,
|
||||
timeout: NotificationDuration.PARTICIPANT_JOINED,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -59,10 +146,10 @@ export const MainNotificationToast = () => {
|
||||
if (isMobileBrowser()) return
|
||||
if (participant.isLocal) return
|
||||
|
||||
const prevMetadata = JSON.parse(prevMetadataStr || '{}')
|
||||
const metadata = JSON.parse(participant.metadata || '{}')
|
||||
const prevMetadata = safeParseMetadata(prevMetadataStr)
|
||||
const metadata = safeParseMetadata(participant.metadata)
|
||||
|
||||
if (prevMetadata.raised == metadata.raised) return
|
||||
if (prevMetadata?.raised == metadata?.raised) return
|
||||
|
||||
const existingToast = toastQueue.visibleToasts.find(
|
||||
(toast) =>
|
||||
@@ -82,7 +169,7 @@ export const MainNotificationToast = () => {
|
||||
participant,
|
||||
type: NotificationType.HandRaised,
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
{ timeout: NotificationDuration.HAND_RAISED }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -104,9 +191,15 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}, [room])
|
||||
|
||||
// Without this line, when the component first renders,
|
||||
// the 'notifications' namespace might not be loaded yet
|
||||
useTranslation(['notifications'])
|
||||
|
||||
return (
|
||||
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
||||
<ToastProvider />
|
||||
<WaitingParticipantNotification />
|
||||
<ReactionPortals reactions={reactions} />
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export enum ToastDuration {
|
||||
SHORT = 3000,
|
||||
MEDIUM = 4000,
|
||||
LONG = 5000,
|
||||
EXTRA_LONG = 7000,
|
||||
}
|
||||
|
||||
export const NotificationDuration = {
|
||||
ALERT: ToastDuration.SHORT,
|
||||
MESSAGE: ToastDuration.LONG,
|
||||
PARTICIPANT_JOINED: ToastDuration.LONG,
|
||||
HAND_RAISED: ToastDuration.LONG,
|
||||
LOWER_HAND: ToastDuration.EXTRA_LONG,
|
||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||
} as const
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NotificationType } from './NotificationType'
|
||||
|
||||
export interface NotificationPayload {
|
||||
type: NotificationType
|
||||
data?: {
|
||||
emoji?: string
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
export enum NotificationType {
|
||||
ParticipantJoined = 'participantJoined',
|
||||
HandRaised = 'handRaised',
|
||||
// todo - implement message received notification
|
||||
ParticipantMuted = 'participantMuted',
|
||||
MessageReceived = 'messageReceived',
|
||||
LowerHand = 'lowerHand',
|
||||
ReactionReceived = 'reactionReceived',
|
||||
ParticipantWaiting = 'participantWaiting',
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const StyledToastContainer = styled('div', {
|
||||
},
|
||||
})
|
||||
|
||||
const StyledToast = styled('div', {
|
||||
export const StyledToast = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
|
||||
export function ToastLowerHand({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications', { keyPrefix: 'lowerHand' })
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const toast = props.toast
|
||||
|
||||
const handleDismiss = () => {
|
||||
// Clear onClose handler to prevent lowering the hand when user dismisses
|
||||
toast.onClose = undefined
|
||||
state.close(toast.key)
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
<p>{t('auto')}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
style={{
|
||||
color: '#60a5fa',
|
||||
marginLeft: '0.5rem',
|
||||
}}
|
||||
onPress={() => handleDismiss()}
|
||||
>
|
||||
{t('dismiss')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { Text } from '@/primitives'
|
||||
import { RiMessage2Line } from '@remixicon/react'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function ToastMessageReceived({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps } = useToast(props, state, ref)
|
||||
|
||||
const toast = props.toast
|
||||
|
||||
const participant = toast.content.participant
|
||||
const message = toast.content.message
|
||||
|
||||
const { isChatOpen, toggleChat } = useSidePanel()
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatOpen) {
|
||||
state.close(toast.key)
|
||||
}
|
||||
}, [isChatOpen, toast, state])
|
||||
|
||||
if (isChatOpen) return null
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<RACButton onPress={() => toggleChat()} aria-label={t('openChat')}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'start',
|
||||
padding: '14px',
|
||||
gap: '0.75rem',
|
||||
textAlign: 'start',
|
||||
width: '250px',
|
||||
md: {
|
||||
width: '350px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'start',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RiMessage2Line
|
||||
size={20}
|
||||
className={css({
|
||||
color: 'primary.300',
|
||||
marginTop: '3px',
|
||||
})}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{participant.name}</span>
|
||||
</div>
|
||||
<Text margin={false} centered={false} wrap={'pretty'} fullWidth>
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
</RACButton>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function ToastMuted({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const participant = props.toast.content.participant
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t('muted', { name: participant.name })}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,52 @@
|
||||
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
|
||||
import type { ToastState } from '@react-stately/toast'
|
||||
import type { QueuedToast, ToastState } from '@react-stately/toast'
|
||||
import { Toast } from './Toast'
|
||||
import { useRef } from 'react'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
import { ToastJoined } from './ToastJoined'
|
||||
import { ToastData } from './ToastProvider'
|
||||
import { ToastRaised } from '@/features/notifications/components/ToastRaised.tsx'
|
||||
import { ToastRaised } from './ToastRaised'
|
||||
import { ToastMuted } from './ToastMuted'
|
||||
import { ToastMessageReceived } from './ToastMessageReceived'
|
||||
import { ToastLowerHand } from './ToastLowerHand'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
}
|
||||
|
||||
const renderToast = (
|
||||
toast: QueuedToast<ToastData>,
|
||||
state: ToastState<ToastData>
|
||||
) => {
|
||||
switch (toast.content?.type) {
|
||||
case NotificationType.ParticipantJoined:
|
||||
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.HandRaised:
|
||||
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.ParticipantMuted:
|
||||
return <ToastMuted key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.MessageReceived:
|
||||
return (
|
||||
<ToastMessageReceived key={toast.key} toast={toast} state={state} />
|
||||
)
|
||||
|
||||
case NotificationType.LowerHand:
|
||||
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
}
|
||||
|
||||
export function ToastRegion({ state, ...props }: ToastRegionProps) {
|
||||
const ref = useRef(null)
|
||||
const { regionProps } = useToastRegion(props, state, ref)
|
||||
return (
|
||||
<div {...regionProps} ref={ref} className="toast-region">
|
||||
{state.visibleToasts.map((toast) => {
|
||||
if (toast.content?.type === NotificationType.ParticipantJoined) {
|
||||
return <ToastJoined key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
if (toast.content?.type === NotificationType.HandRaised) {
|
||||
return <ToastRaised key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
})}
|
||||
{state.visibleToasts.map((toast) => renderToast(toast, state))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import { StyledToastContainer } from './Toast'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiInfinityLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { usePrevious } from '@/hooks/usePrevious'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
|
||||
export const NOTIFICATION_DISPLAY_DURATION = 10000
|
||||
|
||||
export const WaitingParticipantNotification = () => {
|
||||
const { t } = useTranslation('notifications', {
|
||||
keyPrefix: 'waitingParticipants',
|
||||
})
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
const [showQuickActionsMessage, setShowQuickActionsMessage] = useState(false)
|
||||
const { waitingParticipants, handleParticipantEntry } =
|
||||
useWaitingParticipants()
|
||||
const prevWaitingParticipant = usePrevious<WaitingParticipant[] | undefined>(
|
||||
waitingParticipants
|
||||
)
|
||||
|
||||
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
||||
|
||||
useEffect(() => {
|
||||
// Show notification when the first participant enters the waiting room
|
||||
if (
|
||||
!isParticipantListEmpty(waitingParticipants) &&
|
||||
isParticipantListEmpty(prevWaitingParticipant) &&
|
||||
!isParticipantsOpen
|
||||
) {
|
||||
setShowQuickActionsMessage(true)
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowQuickActionsMessage(false)
|
||||
timerRef.current = null // Clear the ref when timeout completes
|
||||
}, NOTIFICATION_DISPLAY_DURATION)
|
||||
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
|
||||
// Hide notification when the participant count changes
|
||||
setShowQuickActionsMessage(false)
|
||||
}
|
||||
}, [waitingParticipants, prevWaitingParticipant, isParticipantsOpen])
|
||||
|
||||
useEffect(() => {
|
||||
// This cleanup function will only run when the component unmounts
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Hide notification when participants panel is opened
|
||||
if (isParticipantsOpen) {
|
||||
setShowQuickActionsMessage(false)
|
||||
}
|
||||
}, [isParticipantsOpen])
|
||||
|
||||
if (!waitingParticipants.length) return null
|
||||
|
||||
return (
|
||||
<StyledToastContainer role="alert">
|
||||
<HStack
|
||||
padding={'1rem'}
|
||||
gap={'1rem'}
|
||||
role={'alertdialog'}
|
||||
aria-label={waitingParticipants.length > 1 ? t('several') : t('one')}
|
||||
aria-modal={false}
|
||||
>
|
||||
{showQuickActionsMessage ? (
|
||||
<VStack gap={'1rem'} alignItems={'start'}>
|
||||
<Text
|
||||
variant="paragraph"
|
||||
margin={false}
|
||||
style={{
|
||||
minWidth: '15rem',
|
||||
}}
|
||||
>
|
||||
{t('one')}
|
||||
</Text>
|
||||
<HStack gap="1rem">
|
||||
<Avatar
|
||||
name={waitingParticipants[0].username}
|
||||
bgColor={waitingParticipants[0].color}
|
||||
context="list"
|
||||
notification
|
||||
/>
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
maxWidth: '10rem',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
})}
|
||||
>
|
||||
{waitingParticipants[0].username}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack gap="0.25rem" marginLeft="auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
style={{
|
||||
color: '#60a5fa',
|
||||
}}
|
||||
onPress={async () => {
|
||||
await handleParticipantEntry(waitingParticipants[0], true)
|
||||
setShowQuickActionsMessage(false)
|
||||
}}
|
||||
>
|
||||
{t('accept')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
style={{
|
||||
color: '#60a5fa',
|
||||
}}
|
||||
onPress={() => {
|
||||
toggleParticipants()
|
||||
setShowQuickActionsMessage(false)
|
||||
}}
|
||||
>
|
||||
{t('open')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<>
|
||||
<HStack gap={0}>
|
||||
<Avatar
|
||||
name={waitingParticipants[0].username}
|
||||
bgColor={waitingParticipants[0].color}
|
||||
context="list"
|
||||
notification
|
||||
/>
|
||||
{waitingParticipants.length > 1 && (
|
||||
<Avatar
|
||||
name={waitingParticipants[1].username}
|
||||
bgColor={waitingParticipants[1].color}
|
||||
context="list"
|
||||
notification
|
||||
style={{
|
||||
marginLeft: '-10px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{waitingParticipants.length > 2 && (
|
||||
<span
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '1rem',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
borderRadius: '50%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: 'primaryDark.100',
|
||||
border: '2px solid white',
|
||||
marginLeft: '-10px',
|
||||
})}
|
||||
>
|
||||
{waitingParticipants.length < 102 ? (
|
||||
<p>+{waitingParticipants.length - 2}</p>
|
||||
) : (
|
||||
<RiInfinityLine size={20} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</HStack>
|
||||
<Text
|
||||
variant="paragraph"
|
||||
margin={false}
|
||||
wrap={'balance'}
|
||||
style={{
|
||||
maxWidth: waitingParticipants.length == 1 ? '10rem' : '15rem',
|
||||
}}
|
||||
>
|
||||
{waitingParticipants.length > 1 ? t('several') : t('one')}
|
||||
</Text>
|
||||
{!isParticipantsOpen && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
style={{
|
||||
color: '#60a5fa',
|
||||
}}
|
||||
onPress={() => {
|
||||
toggleParticipants()
|
||||
}}
|
||||
>
|
||||
{t('open')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { toastQueue } from './components/ToastProvider'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { NotificationDuration } from './NotificationDuration'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { NotificationPayload } from './NotificationPayload'
|
||||
|
||||
export const showLowerHandToast = (
|
||||
participant: Participant,
|
||||
onClose: () => void
|
||||
) => {
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.LowerHand,
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.LOWER_HAND,
|
||||
onClose,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const closeLowerHandToasts = () => {
|
||||
toastQueue.visibleToasts.forEach((toast) => {
|
||||
if (toast.content.type === NotificationType.LowerHand) {
|
||||
toastQueue.close(toast.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const decodeNotificationDataReceived = (
|
||||
payload: Uint8Array
|
||||
): NotificationPayload | undefined => {
|
||||
if (!payload || !(payload instanceof Uint8Array)) {
|
||||
throw new Error('Invalid payload: expected Uint8Array')
|
||||
}
|
||||
try {
|
||||
const decoder = new TextDecoder()
|
||||
const jsonString = decoder.decode(payload)
|
||||
if (!jsonString || typeof jsonString !== 'string') {
|
||||
throw new Error('Invalid decoded content')
|
||||
}
|
||||
// Parse with additional validation if needed
|
||||
const parsed = JSON.parse(jsonString)
|
||||
return parsed as NotificationPayload
|
||||
} catch (error) {
|
||||
// Handle errors appropriately for your application
|
||||
console.error('Failed to decode notification payload:', error)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
export type ApiLiveKit = {
|
||||
url: string
|
||||
room: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export enum ApiAccessLevel {
|
||||
PUBLIC = 'public',
|
||||
TRUSTED = 'trusted',
|
||||
RESTRICTED = 'restricted',
|
||||
}
|
||||
|
||||
export type ApiRoom = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
is_public: boolean
|
||||
is_administrable: boolean
|
||||
livekit?: {
|
||||
url: string
|
||||
room: string
|
||||
token: string
|
||||
}
|
||||
access_level: ApiAccessLevel
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: {
|
||||
[key: string]: string | number | boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
|
||||
export interface EnterRoomParams {
|
||||
roomId: string
|
||||
allowEntry: boolean
|
||||
participantId: string
|
||||
}
|
||||
|
||||
export interface EnterRoomResponse {
|
||||
message?: string
|
||||
}
|
||||
|
||||
export const enterRoom = async ({
|
||||
roomId,
|
||||
allowEntry,
|
||||
participantId,
|
||||
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
||||
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_id: participantId,
|
||||
allow_entry: allowEntry,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useEnterRoom(
|
||||
options?: UseMutationOptions<EnterRoomResponse, ApiError, EnterRoomParams>
|
||||
) {
|
||||
return useMutation<EnterRoomResponse, ApiError, EnterRoomParams>({
|
||||
mutationFn: enterRoom,
|
||||
onSuccess: options?.onSuccess,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { type ApiRoom } from './ApiRoom'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
@@ -11,10 +10,5 @@ export const fetchRoom = ({
|
||||
}) => {
|
||||
return fetchApi<ApiRoom>(
|
||||
`/rooms/${roomId}?username=${encodeURIComponent(username)}`
|
||||
).then((room) => {
|
||||
if (!room.livekit?.token || !room.livekit?.url) {
|
||||
throw new ApiError(500, 'LiveKit info not found')
|
||||
}
|
||||
return room
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
|
||||
export type WaitingParticipant = {
|
||||
id: string
|
||||
status: string
|
||||
username: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type WaitingParticipantsResponse = {
|
||||
participants: WaitingParticipant[]
|
||||
}
|
||||
|
||||
export type WaitingParticipantsParams = {
|
||||
roomId: string
|
||||
}
|
||||
|
||||
export const listWaitingParticipants = async ({
|
||||
roomId,
|
||||
}: WaitingParticipantsParams): Promise<WaitingParticipantsResponse> => {
|
||||
return fetchApi<WaitingParticipantsResponse>(
|
||||
`/rooms/${roomId}/waiting-participants/`,
|
||||
{
|
||||
method: 'GET',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const useListWaitingParticipants = (
|
||||
roomId: string,
|
||||
queryOptions?: Omit<
|
||||
UseQueryOptions<
|
||||
WaitingParticipantsResponse,
|
||||
ApiError,
|
||||
WaitingParticipantsResponse
|
||||
>,
|
||||
'queryKey'
|
||||
>
|
||||
) => {
|
||||
return useQuery<
|
||||
WaitingParticipantsResponse,
|
||||
ApiError,
|
||||
WaitingParticipantsResponse
|
||||
>({
|
||||
queryKey: [keys.waitingParticipants, roomId],
|
||||
queryFn: () => listWaitingParticipants({ roomId }),
|
||||
...queryOptions,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type ApiRoom } from './ApiRoom'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
|
||||
export type PatchRoomParams = {
|
||||
roomId: string
|
||||
room: Pick<ApiRoom, 'configuration' | 'access_level'>
|
||||
}
|
||||
|
||||
export const patchRoom = ({ roomId, room }: PatchRoomParams) => {
|
||||
return fetchApi<ApiRoom>(`/rooms/${roomId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(room),
|
||||
})
|
||||
}
|
||||
|
||||
export function usePatchRoom(
|
||||
options?: UseMutationOptions<ApiRoom, ApiError, PatchRoomParams>
|
||||
) {
|
||||
return useMutation<ApiRoom, ApiError, PatchRoomParams>({
|
||||
mutationFn: patchRoom,
|
||||
onSuccess: options?.onSuccess,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export interface RequestEntryParams {
|
||||
roomId: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
export enum ApiLobbyStatus {
|
||||
IDLE = 'idle',
|
||||
WAITING = 'waiting',
|
||||
DENIED = 'denied',
|
||||
TIMEOUT = 'timeout',
|
||||
ACCEPTED = 'accepted',
|
||||
}
|
||||
|
||||
export interface ApiRequestEntry {
|
||||
status: ApiLobbyStatus
|
||||
livekit?: ApiLiveKit
|
||||
}
|
||||
|
||||
export const requestEntry = async ({
|
||||
roomId,
|
||||
username = '',
|
||||
}: RequestEntryParams) => {
|
||||
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export const Conference = ({
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
const fetchKey = [keys.room, roomId, userConfig.username]
|
||||
const fetchKey = [keys.room, roomId]
|
||||
|
||||
const {
|
||||
mutateAsync: createRoom,
|
||||
@@ -49,6 +49,7 @@ export const Conference = ({
|
||||
isError: isFetchError,
|
||||
data,
|
||||
} = useQuery({
|
||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||
queryKey: fetchKey,
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
initialData: initialRoomData,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { usePreviewTracks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useMemo, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
||||
@@ -19,6 +19,15 @@ import {
|
||||
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useLobby } from '../hooks/useLobby'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
|
||||
const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
@@ -99,8 +108,10 @@ const Effects = ({
|
||||
|
||||
export const Join = ({
|
||||
onSubmit,
|
||||
roomId,
|
||||
}: {
|
||||
onSubmit: (choices: LocalUserChoices) => void
|
||||
roomId: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
@@ -195,7 +206,7 @@ export const Join = ({
|
||||
}
|
||||
}, [videoTrack, videoEnabled])
|
||||
|
||||
function handleSubmit() {
|
||||
const enterRoom = useCallback(() => {
|
||||
onSubmit({
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
@@ -204,6 +215,72 @@ export const Join = ({
|
||||
username,
|
||||
processorSerialized: processor?.serialize(),
|
||||
})
|
||||
}, [
|
||||
onSubmit,
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
username,
|
||||
processor,
|
||||
])
|
||||
|
||||
// Room data strategy:
|
||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
||||
// 3. State is manually updated via queryClient when a waiting participant is accepted
|
||||
// 4. No automatic refetching or revalidation occurs during this period
|
||||
// todo - refactor in a hook
|
||||
const {
|
||||
data: roomData,
|
||||
error,
|
||||
isError,
|
||||
refetch: refetchRoom,
|
||||
} = useQuery({
|
||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId, username }),
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
retry: false,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isError && error?.statusCode == 404) {
|
||||
// The room component will handle the room creation if the user is authenticated
|
||||
enterRoom()
|
||||
}
|
||||
}, [isError, error, enterRoom])
|
||||
|
||||
const handleAccepted = (response: ApiRequestEntry) => {
|
||||
queryClient.setQueryData([keys.room, roomId], {
|
||||
...roomData,
|
||||
livekit: response.livekit,
|
||||
})
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
const { status, startWaiting } = useLobby({
|
||||
roomId,
|
||||
username,
|
||||
onAccepted: handleAccepted,
|
||||
})
|
||||
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { data } = await refetchRoom()
|
||||
|
||||
if (!data?.livekit) {
|
||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
||||
if (data?.access_level == ApiAccessLevel.TRUSTED) {
|
||||
openLoginHint()
|
||||
}
|
||||
startWaiting()
|
||||
return
|
||||
}
|
||||
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
// This hook is used to setup the persisted user choice processor on initialization.
|
||||
@@ -216,6 +293,84 @@ export const Join = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoTrack])
|
||||
|
||||
const renderWaitingState = () => {
|
||||
switch (status) {
|
||||
case ApiLobbyStatus.TIMEOUT:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false}>
|
||||
{t('timeoutInvite.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('timeoutInvite.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
|
||||
case ApiLobbyStatus.DENIED:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false}>
|
||||
{t('denied.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('denied.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
|
||||
case ApiLobbyStatus.WAITING:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false}>
|
||||
{t('waiting.title')}
|
||||
</H>
|
||||
<Text
|
||||
as="p"
|
||||
variant="note"
|
||||
className={css({ marginBottom: '1.5rem' })}
|
||||
>
|
||||
{t('waiting.body')}
|
||||
</Text>
|
||||
<Spinner />
|
||||
</VStack>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={t('joinLabel')}
|
||||
submitButtonProps={{
|
||||
fullWidth: true,
|
||||
}}
|
||||
>
|
||||
<VStack marginBottom={1}>
|
||||
<H lvl={1} margin="sm">
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="text"
|
||||
onChange={setUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={initialUserChoices?.username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
maxLength={50}
|
||||
/>
|
||||
</VStack>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen footer={false}>
|
||||
<div
|
||||
@@ -353,35 +508,7 @@ export const Join = ({
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={t('joinLabel')}
|
||||
submitButtonProps={{
|
||||
fullWidth: true,
|
||||
}}
|
||||
>
|
||||
<VStack marginBottom={1}>
|
||||
<H lvl={1} margin={false}>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="text"
|
||||
onChange={setUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={initialUserChoices?.username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
maxLength={50}
|
||||
/>
|
||||
</VStack>
|
||||
</Form>
|
||||
{renderWaitingState()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Button, H, Text, TextArea } from '@/primitives'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, H, Input, Text, TextArea } from '@/primitives'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { cva } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { PostHog } from 'posthog-js'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
|
||||
const Card = styled('div', {
|
||||
base: {
|
||||
@@ -170,7 +171,7 @@ const RateQuality = ({
|
||||
<H lvl={3}>{t('question')}</H>
|
||||
<Bar>
|
||||
{[...Array(maxRating)].map((_, index) => (
|
||||
<Button
|
||||
<RACButton
|
||||
key={index}
|
||||
onPress={() => handleRatingClick(index + 1)}
|
||||
className={ratingButtonRecipe({
|
||||
@@ -179,7 +180,7 @@ const RateQuality = ({
|
||||
})}
|
||||
>
|
||||
{index + 1}
|
||||
</Button>
|
||||
</RACButton>
|
||||
))}
|
||||
</Bar>
|
||||
<div
|
||||
@@ -236,9 +237,74 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const AuthenticationMessage = ({
|
||||
onNext,
|
||||
posthog,
|
||||
}: {
|
||||
onNext: () => void
|
||||
posthog: PostHog
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'authenticationMessage' })
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const onSubmit = () => {
|
||||
posthog.people.set({ unsafe_email: email })
|
||||
onNext()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
maxWidth: '380px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<H lvl={3}>{t('heading')}</H>
|
||||
<Input
|
||||
id="emailInput"
|
||||
name="email"
|
||||
placeholder={t('placeholder')}
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
/>
|
||||
<VStack gap="0.5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
isDisabled={!email}
|
||||
onPress={onSubmit}
|
||||
>
|
||||
{t('submit')}
|
||||
</Button>
|
||||
<Button
|
||||
invisible
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onPress={onNext}
|
||||
>
|
||||
{t('ignore')}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const Rating = () => {
|
||||
const posthog = usePostHog()
|
||||
|
||||
const isUserAnonymous = useMemo(() => {
|
||||
return posthog.get_property('$user_state') == 'anonymous'
|
||||
}, [posthog])
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
if (step == 0) {
|
||||
@@ -250,6 +316,17 @@ export const Rating = () => {
|
||||
}
|
||||
|
||||
if (step == 2) {
|
||||
return isUserAnonymous ? (
|
||||
<AuthenticationMessage
|
||||
posthog={posthog}
|
||||
onNext={() => setStep(step + 1)}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmationMessage onNext={() => setStep(0)} />
|
||||
)
|
||||
}
|
||||
|
||||
if (step == 3) {
|
||||
return <ConfirmationMessage onNext={() => setStep(0)} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import {
|
||||
requestEntry,
|
||||
ApiLobbyStatus,
|
||||
ApiRequestEntry,
|
||||
} from '../api/requestEntry'
|
||||
|
||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
|
||||
export const useLobby = ({
|
||||
roomId,
|
||||
username,
|
||||
onAccepted,
|
||||
}: {
|
||||
roomId: string
|
||||
username: string
|
||||
onAccepted: (e: ApiRequestEntry) => void
|
||||
}) => {
|
||||
const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
|
||||
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const clearWaitingTimeout = useCallback(() => {
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
waitingTimeoutRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startWaitingTimeout = useCallback(() => {
|
||||
clearWaitingTimeout()
|
||||
waitingTimeoutRef.current = setTimeout(() => {
|
||||
setStatus(ApiLobbyStatus.TIMEOUT)
|
||||
}, WAIT_TIMEOUT_MS)
|
||||
}, [clearWaitingTimeout])
|
||||
|
||||
const { data: waitingData } = useQuery({
|
||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||
queryKey: [keys.requestEntry, roomId],
|
||||
queryFn: async () => {
|
||||
const response = await requestEntry({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||
onAccepted(response)
|
||||
} else if (response.status === ApiLobbyStatus.DENIED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.DENIED)
|
||||
}
|
||||
return response
|
||||
},
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchIntervalInBackground: true,
|
||||
enabled: status === ApiLobbyStatus.WAITING,
|
||||
})
|
||||
|
||||
const startWaiting = useCallback(() => {
|
||||
setStatus(ApiLobbyStatus.WAITING)
|
||||
startWaitingTimeout()
|
||||
}, [startWaitingTimeout])
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearWaitingTimeout()
|
||||
}, [clearWaitingTimeout])
|
||||
|
||||
return {
|
||||
status,
|
||||
startWaiting,
|
||||
waitingData,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useEnterRoom } from '../api/enterRoom'
|
||||
import {
|
||||
useListWaitingParticipants,
|
||||
WaitingParticipant,
|
||||
} from '../api/listWaitingParticipants'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
|
||||
export const useWaitingParticipants = () => {
|
||||
const [listEnabled, setListEnabled] = useState(true)
|
||||
|
||||
const roomData = useRoomData()
|
||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||
|
||||
const room = useRoomContext()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const handleDataReceived = useCallback((payload: Uint8Array) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
if (notification?.type === NotificationType.ParticipantWaiting) {
|
||||
setListEnabled(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdminOrOwner) {
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
}, [isAdminOrOwner, room, handleDataReceived])
|
||||
|
||||
const { data: waitingData, refetch: refetchWaiting } =
|
||||
useListWaitingParticipants(roomId, {
|
||||
retry: false,
|
||||
enabled: listEnabled && isAdminOrOwner,
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
|
||||
const waitingParticipants = useMemo(
|
||||
() => waitingData?.participants || [],
|
||||
[waitingData]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingParticipants.length) setListEnabled(false)
|
||||
}, [waitingParticipants])
|
||||
|
||||
const { mutateAsync: enterRoom } = useEnterRoom()
|
||||
|
||||
const handleParticipantEntry = async (
|
||||
participant: WaitingParticipant,
|
||||
allowEntry: boolean
|
||||
) => {
|
||||
await enterRoom({
|
||||
roomId: roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
})
|
||||
await refetchWaiting()
|
||||
}
|
||||
|
||||
const handleParticipantsEntry = async (
|
||||
allowEntry: boolean
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setListEnabled(false)
|
||||
for (const participant of waitingParticipants) {
|
||||
await enterRoom({
|
||||
roomId: roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
})
|
||||
}
|
||||
await refetchWaiting()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setListEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
waitingParticipants,
|
||||
handleParticipantEntry,
|
||||
handleParticipantsEntry,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Participant } from 'livekit-client'
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
export const useLowerHandParticipant = () => {
|
||||
const data = useRoomData()
|
||||
@@ -10,7 +11,7 @@ export const useLowerHandParticipant = () => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
const newMetadata = JSON.parse(participant.metadata || '{}')
|
||||
const newMetadata = safeParseMetadata(participant.metadata) || {}
|
||||
newMetadata.raised = !newMetadata.raised
|
||||
return fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
|
||||
@@ -3,11 +3,27 @@ import Source = Track.Source
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const data = useRoomData()
|
||||
const room = useRoomContext()
|
||||
|
||||
const muteParticipant = (participant: Participant) => {
|
||||
const notifyParticipant = async (participant: Participant) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
type: NotificationType.ParticipantMuted,
|
||||
}
|
||||
const data = encoder.encode(JSON.stringify(payload))
|
||||
await room.localParticipant.publishData(data, {
|
||||
reliable: true,
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
}
|
||||
|
||||
const muteParticipant = async (participant: Participant) => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
@@ -18,22 +34,33 @@ export const useMuteParticipant = () => {
|
||||
if (!trackSid) {
|
||||
throw new Error('Missing audio track')
|
||||
}
|
||||
return fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
data.livekit.url,
|
||||
'twirp/livekit.RoomService/MutePublishedTrack'
|
||||
),
|
||||
data.livekit.token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
room: data.livekit.room,
|
||||
identity: participant.identity,
|
||||
muted: true,
|
||||
track_sid: trackSid,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
data.livekit.url,
|
||||
'twirp/livekit.RoomService/MutePublishedTrack'
|
||||
),
|
||||
data.livekit.token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
room: data.livekit.room,
|
||||
identity: participant.identity,
|
||||
muted: true,
|
||||
track_sid: trackSid,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
await notifyParticipant(participant)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { muteParticipant }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Div, Field, H, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Separator as RACSeparator } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
|
||||
const { roomId } = useParams()
|
||||
|
||||
if (!roomId) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
|
||||
const { data: readOnlyData } = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId }),
|
||||
retry: false,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="pretty"
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
})}
|
||||
margin={'md'}
|
||||
>
|
||||
{t('description')}
|
||||
</Text>
|
||||
<RACSeparator
|
||||
className={css({
|
||||
border: 'none',
|
||||
height: '1px',
|
||||
width: '100%',
|
||||
background: '#dadce0',
|
||||
})}
|
||||
/>
|
||||
<H
|
||||
lvl={2}
|
||||
className={css({
|
||||
fontWeight: 500,
|
||||
})}
|
||||
>
|
||||
{t('access.title')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="balance"
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
})}
|
||||
margin={'md'}
|
||||
>
|
||||
{t('access.description')}
|
||||
</Text>
|
||||
<Field
|
||||
type="radioGroup"
|
||||
label={t('access.type')}
|
||||
aria-label={t('access.type')}
|
||||
labelProps={{
|
||||
className: css({
|
||||
fontSize: '1rem',
|
||||
paddingBottom: '1rem',
|
||||
}),
|
||||
}}
|
||||
value={readOnlyData?.access_level}
|
||||
onChange={(value) =>
|
||||
patchRoom({ roomId, room: { access_level: value as ApiAccessLevel } })
|
||||
.then((room) => {
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
})
|
||||
.catch((e) => console.error(e))
|
||||
}
|
||||
items={[
|
||||
{
|
||||
value: ApiAccessLevel.PUBLIC,
|
||||
label: t('access.levels.public.label'),
|
||||
description: t('access.levels.public.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.TRUSTED,
|
||||
label: t('access.levels.trusted.label'),
|
||||
description: t('access.levels.trusted.description'),
|
||||
},
|
||||
{
|
||||
value: ApiAccessLevel.RESTRICTED,
|
||||
label: t('access.levels.restricted.label'),
|
||||
description: t('access.levels.restricted.description'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { RiAdminLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
|
||||
export const AdminToggle = ({
|
||||
variant = 'primaryTextDark',
|
||||
onPress,
|
||||
...props
|
||||
}: ToggleButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.admin' })
|
||||
|
||||
const { isAdminOpen, toggleAdmin } = useSidePanel()
|
||||
const tooltipLabel = isAdminOpen ? 'open' : 'closed'
|
||||
|
||||
const hasAdminAccess = useIsAdminOrOwner()
|
||||
if (!hasAdminAccess) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
variant={variant}
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isAdminOpen}
|
||||
onPress={(e) => {
|
||||
toggleAdmin()
|
||||
onPress?.(e)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<RiAdminLine />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { useSize } from '../hooks/useResizeObserver'
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const FullScreenShareWarning = ({
|
||||
trackReference,
|
||||
}: {
|
||||
trackReference: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'fullScreenWarning' })
|
||||
|
||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { width: containerWidth } = useSize(warningContainerRef)
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
|
||||
|
||||
const isFullScreenSharing = useMemo(() => {
|
||||
if (trackReference?.source !== 'screen_share') return false
|
||||
|
||||
const mediaStreamTrack = trackReference.publication?.track?.mediaStreamTrack
|
||||
|
||||
if (!mediaStreamTrack) return false
|
||||
|
||||
// Method 1: Display Surface Check (Chrome & Edge)
|
||||
const trackSettings = mediaStreamTrack.getSettings()
|
||||
if (trackSettings?.displaySurface) {
|
||||
return trackSettings.displaySurface === 'monitor'
|
||||
}
|
||||
|
||||
// Method 2: Track Constraints Check
|
||||
const constraints = mediaStreamTrack.getConstraints()
|
||||
if (constraints?.displaySurface) {
|
||||
return constraints.displaySurface === 'monitor'
|
||||
}
|
||||
|
||||
// Method 3: Label Analysis (Firefox, Safari)
|
||||
const trackLabel = mediaStreamTrack.label.toLowerCase()
|
||||
const fullScreenIndicators = [
|
||||
'monitor',
|
||||
'screen',
|
||||
'display',
|
||||
'entire screen',
|
||||
'full screen',
|
||||
]
|
||||
return fullScreenIndicators.some((indicator) =>
|
||||
trackLabel.includes(indicator)
|
||||
)
|
||||
}, [trackReference])
|
||||
|
||||
const shouldShowWarning =
|
||||
screenSharePreferences.enabled && isFullScreenSharing
|
||||
|
||||
const handleStopScreenShare = async () => {
|
||||
if (!localParticipant.isScreenShareEnabled) return
|
||||
await localParticipant.setScreenShareEnabled(false, {}, {})
|
||||
}
|
||||
|
||||
const handleDismissWarning = () => {
|
||||
ScreenSharePreferenceStore.enabled = false
|
||||
}
|
||||
|
||||
if (!shouldShowWarning) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
zIndex: '1000',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
})}
|
||||
ref={warningContainerRef}
|
||||
>
|
||||
{(!containerWidth || containerWidth >= 300) && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
zIndex: '1000',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
backgroundColor: 'rgba(22, 22, 34, 0.9)',
|
||||
padding: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
md: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: 'white',
|
||||
flexBasis: '55%',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
margin={false}
|
||||
wrap={'pretty'}
|
||||
>
|
||||
{t('message')}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
style={{
|
||||
height: 'fit-content',
|
||||
}}
|
||||
onPress={async () => {
|
||||
await handleStopScreenShare()
|
||||
}}
|
||||
>
|
||||
{t('stop')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
style={{
|
||||
height: 'fit-content',
|
||||
}}
|
||||
onPress={() => handleDismissWarning()}
|
||||
>
|
||||
{t('ignore')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Dialog, P } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
export const MuteAlertDialog = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
name,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: () => void
|
||||
name: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'participants.muteParticipantAlert',
|
||||
})
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('heading', { name })}
|
||||
>
|
||||
<P>{t('description', { name })}</P>
|
||||
<HStack gap={1}>
|
||||
<Button variant="text" size="sm" onPress={onClose}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button variant="text" size="sm" onPress={onSubmit}>
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export const MutedMicIndicator = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Div padding={0.25} backgroundColor="red" borderRadius="4px">
|
||||
<Div padding={0.25} backgroundColor="#E1000F" borderRadius="4px">
|
||||
<RiMicOffFill size={16} color="white" />
|
||||
</Div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
FocusToggle,
|
||||
LockLockedIcon,
|
||||
ParticipantName,
|
||||
ParticipantTileProps,
|
||||
@@ -23,11 +22,13 @@ import {
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { useRaisedHand } from '../hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { MutedMicIndicator } from '@/features/rooms/livekit/components/MutedMicIndicator'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -100,6 +101,7 @@ export const ParticipantTile: (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
{children ?? (
|
||||
<>
|
||||
{isTrackReference(trackReference) &&
|
||||
@@ -173,7 +175,9 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!disableMetadata && <FocusToggle trackRef={trackReference} />}
|
||||
{!disableMetadata && (
|
||||
<ParticipantTileFocus trackRef={trackReference} />
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import {
|
||||
RiFullscreenLine,
|
||||
RiImageCircleAiFill,
|
||||
RiMicLine,
|
||||
RiMicOffLine,
|
||||
RiPushpin2Line,
|
||||
RiUnpinLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useFocusToggle,
|
||||
useTrackMutedIndicator,
|
||||
} from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useFullScreen } from '../hooks/useFullScreen'
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import { MuteAlertDialog } from './MuteAlertDialog'
|
||||
import { useMuteParticipant } from '../api/muteParticipant'
|
||||
|
||||
const ZoomButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||
trackRef,
|
||||
})
|
||||
|
||||
if (!isFullscreenAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('fullScreen')}
|
||||
onPress={() => toggleFullScreen()}
|
||||
>
|
||||
<RiFullscreenLine />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const FocusButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { mergedProps, inFocus } = useFocusToggle({
|
||||
trackRef,
|
||||
props: {},
|
||||
})
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onPress={(event) => mergedProps?.onClick?.(event as any)}
|
||||
>
|
||||
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const EffectsButton = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
tooltip={t('effects')}
|
||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
||||
>
|
||||
<RiImageCircleAiFill />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const { isMuted } = useTrackMutedIndicator({
|
||||
participant: participant,
|
||||
source: Track.Source.Microphone,
|
||||
})
|
||||
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => setIsAlertOpen(true)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [opacity, setOpacity] = useState(0)
|
||||
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
const [isIdleRef, setIsIdleRef] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered && !isIdleRef) {
|
||||
// Wait for next frame to ensure element is mounted
|
||||
requestAnimationFrame(() => {
|
||||
setOpacity(0.6)
|
||||
})
|
||||
} else {
|
||||
setOpacity(0)
|
||||
}
|
||||
}, [hovered, isIdleRef])
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (idleTimerRef.current) {
|
||||
window.clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
setIsIdleRef(true)
|
||||
}, MOUSE_IDLE_TIME)
|
||||
setIsIdleRef(false)
|
||||
}
|
||||
|
||||
const participant = trackRef.participant
|
||||
|
||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||
const isLocal = trackRef.participant.isLocal
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
})}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{hovered && (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
transition: 'opacity 200ms linear',
|
||||
zIndex: 1,
|
||||
borderRadius: '0.25rem',
|
||||
display: 'flex',
|
||||
_hover: {
|
||||
opacity: '0.95 !important',
|
||||
},
|
||||
})}
|
||||
style={{ opacity }}
|
||||
>
|
||||
<HStack
|
||||
gap={0.5}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
_hover: {
|
||||
opacity: '1 !important',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<FocusButton trackRef={trackRef} />
|
||||
{!isScreenShare ? (
|
||||
<>
|
||||
{participant.isLocal ? (
|
||||
<EffectsButton />
|
||||
) : (
|
||||
<MuteButton participant={participant} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
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'
|
||||
|
||||
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
|
||||
|
||||
interface FloatingReactionProps {
|
||||
emoji: string
|
||||
name?: string
|
||||
speed?: number
|
||||
scale?: number
|
||||
}
|
||||
|
||||
export function FloatingReaction({
|
||||
emoji,
|
||||
name,
|
||||
speed = 1,
|
||||
scale = 1,
|
||||
}: FloatingReactionProps) {
|
||||
const [deltaY, setDeltaY] = useState(0)
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
|
||||
const left = useMemo(
|
||||
() => Math.random() * window.innerWidth * REACTION_SPAWN_WIDTH_RATIO,
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let start: number | null = null
|
||||
function animate(timestamp: number) {
|
||||
if (start === null) start = timestamp
|
||||
const elapsed = timestamp - start
|
||||
if (elapsed < 0) {
|
||||
setOpacity(0)
|
||||
} else {
|
||||
const progress = Math.min(elapsed / ANIMATION_DURATION, 1)
|
||||
const distance = ANIMATION_DISTANCE * speed
|
||||
const newY = progress * distance
|
||||
setDeltaY(newY)
|
||||
if (progress > FADE_OUT_THRESHOLD) {
|
||||
setOpacity(1 - (progress - FADE_OUT_THRESHOLD) / 0.3)
|
||||
}
|
||||
}
|
||||
if (elapsed < ANIMATION_DURATION) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
const req = requestAnimationFrame(animate)
|
||||
return () => cancelAnimationFrame(req)
|
||||
}, [speed])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3rem',
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
style={{
|
||||
left: left,
|
||||
bottom: INITIAL_POSITION + deltaY,
|
||||
transform: `scale(${scale})`,
|
||||
opacity: opacity,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
lineHeight: '45px',
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
{name && (
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.100',
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
borderRadius: '20px',
|
||||
paddingX: '0.5rem',
|
||||
paddingY: '0.15rem',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
lineHeight: '14px',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
||||
return createPortal(
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
>
|
||||
<FloatingReaction
|
||||
emoji={emoji}
|
||||
speed={speed}
|
||||
scale={scale}
|
||||
name={participant?.isLocal ? t('you') : participant.name}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
|
||||
reactions.map((instance) => (
|
||||
<ReactionPortal
|
||||
key={instance.id}
|
||||
emoji={instance.emoji}
|
||||
participant={instance.participant}
|
||||
/>
|
||||
))
|
||||
@@ -0,0 +1,66 @@
|
||||
import { A, Button, Dialog, P } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
// todo - refactor it into a generic system
|
||||
export const ScreenShareErrorModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
||||
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
role="alertdialog"
|
||||
title={t('title')}
|
||||
aria-label={t('ariaLabel')}
|
||||
onClose={onClose}
|
||||
>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<>
|
||||
<P>
|
||||
{t('message')}{' '}
|
||||
{isMac && (
|
||||
<>
|
||||
{t('macInstructions')}{' '}
|
||||
<A
|
||||
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
||||
color="primary"
|
||||
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
|
||||
>
|
||||
{t('macSystemPreferences')}
|
||||
</A>
|
||||
.{' '}
|
||||
</>
|
||||
)}
|
||||
{t('helpLinkText')}{' '}
|
||||
<A
|
||||
href="https://lasuite.crisp.help/fr/article/visio-probleme-de-presentation-1xkf799/"
|
||||
aria-label={t('helpLinkLabel') + '-' + t('newTab')}
|
||||
target="_blank"
|
||||
color="primary"
|
||||
>
|
||||
{t('helpLinkLabel')}
|
||||
</A>
|
||||
.
|
||||
</P>
|
||||
<Button
|
||||
onPress={close}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
className={css({ marginLeft: 'auto', marginTop: '2rem' })}
|
||||
>
|
||||
{t('closeButton')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { layoutStore } from '@/stores/layout'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { text } from '@/primitives/Text'
|
||||
import { Box, Button, Div } from '@/primitives'
|
||||
import { Button, Div } from '@/primitives'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
@@ -11,6 +11,7 @@ import { ReactNode } from 'react'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
import { Transcript } from './Transcript'
|
||||
import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -27,9 +28,16 @@ const StyledSidePanel = ({
|
||||
isClosed,
|
||||
closeButtonTooltip,
|
||||
}: StyledSidePanelProps) => (
|
||||
<Box
|
||||
size="sm"
|
||||
<div
|
||||
className={css({
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'box.border',
|
||||
backgroundColor: 'box.bg',
|
||||
color: 'box.text',
|
||||
borderRadius: 8,
|
||||
flex: 1,
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -40,7 +48,6 @@ const StyledSidePanel = ({
|
||||
top: 0,
|
||||
bottom: '80px',
|
||||
width: '360px',
|
||||
position: 'absolute',
|
||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||
})}
|
||||
style={{
|
||||
@@ -79,7 +86,7 @@ const StyledSidePanel = ({
|
||||
</Button>
|
||||
</Div>
|
||||
{children}
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
|
||||
type PanelProps = {
|
||||
@@ -108,6 +115,7 @@ export const SidePanel = () => {
|
||||
isChatOpen,
|
||||
isSidePanelOpen,
|
||||
isTranscriptOpen,
|
||||
isAdminOpen,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
@@ -132,6 +140,9 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isTranscriptOpen}>
|
||||
<Transcript />
|
||||
</Panel>
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
<Admin />
|
||||
</Panel>
|
||||
</StyledSidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user