Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 564d31ab49 | |||
| 6e0948c696 | |||
| eff0cb4afb | |||
| 08c5245b48 | |||
| 465bf293f0 | |||
| db51ca1aa5 | |||
| 6935001aab | |||
| bbc2b1d9f6 | |||
| 12e2149b9e | |||
| 3bd9363879 | |||
| b48135c3b6 | |||
| 206e74c645 | |||
| 9ebfc8ea29 | |||
| 03796fcbb2 | |||
| 4b6bd3d1c8 | |||
| d45880ab5c | |||
| 4ae3d965f9 | |||
| 8eab45b6d5 | |||
| 4347d87f33 | |||
| 1b52d76168 | |||
| a44b6e8e34 | |||
| 126de638cd | |||
| bf28c5cb84 | |||
| 4f0e7d2c52 | |||
| 3ab5c48296 | |||
| 7f1f573af8 | |||
| d48a18b36b | |||
| eac9158734 | |||
| 994f335266 |
@@ -11,34 +11,17 @@ jobs:
|
||||
notify-argocd:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
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: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
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 ''${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}" $ARGOCD_WEBHOOK_URL
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_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}" $ARGOCD_PRODUCTION_WEBHOOK_URL
|
||||
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 }}
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_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}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
|
||||
|
||||
start-test-on-preprod:
|
||||
needs:
|
||||
|
||||
@@ -19,26 +19,9 @@ jobs:
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
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: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -48,7 +31,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -69,26 +52,9 @@ jobs:
|
||||
build-and-push-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
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: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -98,7 +64,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -120,26 +86,9 @@ jobs:
|
||||
build-and-push-summary:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
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: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -149,7 +98,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -162,39 +111,20 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- 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:
|
||||
-
|
||||
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: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
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 ''${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}" $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 +1,4 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
@@ -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 .'))
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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}_*") == []
|
||||
@@ -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 = {
|
||||
@@ -411,8 +431,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 +484,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 +499,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):
|
||||
|
||||
@@ -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,40 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.36.2",
|
||||
"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 +70,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.2",
|
||||
"types-requests==2.32.0.20241016",
|
||||
"ruff==0.9.9",
|
||||
"types-requests==2.32.0.20250301",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -34,6 +34,11 @@ 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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,48 +13,48 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.8.0",
|
||||
"@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.1",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"i18next-parser": "9.1.0",
|
||||
"i18next": "24.2.2",
|
||||
"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.207.0",
|
||||
"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",
|
||||
"valtio": "2.1.2",
|
||||
"wouter": "3.3.5"
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.1.3",
|
||||
"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.7",
|
||||
"@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.10",
|
||||
"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.
|
||||
|
||||
|
After Width: | Height: | Size: 958 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 955 KiB |
|
After Width: | Height: | Size: 986 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,11 @@ const avatar = cva({
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
true: {
|
||||
border: '2px solid white',
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
context: 'list',
|
||||
@@ -37,14 +42,22 @@ 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}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const FeedbackBanner = () => {
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<RiErrorWarningLine size={20} />
|
||||
<RiErrorWarningLine size={20} aria-hidden="true" />
|
||||
<Text as="p">{t('feedback.context')}</Text>
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -9,15 +9,16 @@ import { useUser, UserAware } from '@/features/auth'
|
||||
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
import { useCreateRoom } from '@/features/rooms'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
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 (
|
||||
@@ -156,6 +157,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 { Bold, H, P, A, Italic, Ul } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const AccessibilityRoute = () => {
|
||||
const { t } = useTranslation('accessibility', { keyPrefix: 'accessibility' })
|
||||
|
||||
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'}>
|
||||
<P dangerouslySetInnerHTML={{ __html: t('introduction') }}></P>
|
||||
|
||||
<H lvl={2} bold>
|
||||
{t('declaration.title')}
|
||||
</H>
|
||||
<Italic>{t('declaration.date')}</Italic>
|
||||
<P>{t('scope')}</P>
|
||||
|
||||
<H lvl={2} bold>
|
||||
{t('complianceStatus.title')}
|
||||
</H>
|
||||
<P>{t('complianceStatus.body')}</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:visio@numerique.gouv.fr" color="primary">
|
||||
visio@numerique.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>
|
||||
{t('recourse.options.option3')}
|
||||
<P className={indentedStyle}>
|
||||
<Bold>{t('dataProtection.line1')}</Bold>
|
||||
<br />
|
||||
{t('dataProtection.line2')}
|
||||
<br />
|
||||
{t('dataProtection.line3')}
|
||||
<br />
|
||||
{t('dataProtection.line4')}
|
||||
</P>
|
||||
</li>
|
||||
</Ul>
|
||||
</HStack>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Bold, H, P, A } 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'}>
|
||||
<H lvl={2}>{t('creator.title')}</H>
|
||||
<P>{t('creator.body')}</P>
|
||||
<H lvl={3} bold>
|
||||
{t('creator.contact.title')}
|
||||
</H>
|
||||
<P>
|
||||
{t('creator.contact.address', {
|
||||
address: 'DINUM, 20 avenue de Ségur, 75007 Paris',
|
||||
})}
|
||||
<br />
|
||||
{t('creator.contact.phone', {
|
||||
phone: '01.71.21.01.70',
|
||||
})}
|
||||
<br />
|
||||
{t('creator.contact.siret', {
|
||||
siret: '12000101100010 (secrétariat général du gouvernement)',
|
||||
})}
|
||||
<br />
|
||||
{t('creator.contact.siren', {
|
||||
siren: '120 001 011',
|
||||
})}
|
||||
</P>
|
||||
|
||||
<H lvl={2}>{t('director.title')}</H>
|
||||
<P>{t('director.body')}</P>
|
||||
|
||||
<H lvl={2}>{t('hosting.title')}</H>
|
||||
<P>{t('hosting.body')}</P>
|
||||
<P className={indentedStyle}>
|
||||
<Bold>{t('hosting.address.line1')}</Bold>
|
||||
<br />
|
||||
{t('hosting.address.line2')}
|
||||
<br />
|
||||
{t('hosting.address.line3')}
|
||||
<br />
|
||||
{t('hosting.address.line4')}
|
||||
</P>
|
||||
|
||||
<H lvl={2}>{t('accessibility.title')}</H>
|
||||
<P>{t('accessibility.body')}</P>
|
||||
<P>
|
||||
<Bold>{t('accessibility.status')}</Bold>
|
||||
</P>
|
||||
|
||||
<H lvl={3} bold>
|
||||
{t('accessibility.reporting.title')}
|
||||
</H>
|
||||
<P>
|
||||
{t('accessibility.reporting.body1').replace(
|
||||
'visio@numerique.gouv.fr.',
|
||||
''
|
||||
)}
|
||||
<A href="mailto:visio@numerique.gouv.fr" color="primary">
|
||||
visio@numerique.gouv.fr
|
||||
</A>
|
||||
.
|
||||
</P>
|
||||
<P>
|
||||
{t('accessibility.reporting.body2').replace(
|
||||
'https://formulaire.defenseurdesdroits.fr',
|
||||
''
|
||||
)}{' '}
|
||||
<A href="https://formulaire.defenseurdesdroits.fr" color="primary">
|
||||
https://formulaire.defenseurdesdroits.fr
|
||||
</A>{' '}
|
||||
{
|
||||
t('accessibility.reporting.body2').split(
|
||||
'https://formulaire.defenseurdesdroits.fr'
|
||||
)[1]
|
||||
}
|
||||
</P>
|
||||
<P className={indentedStyle}>
|
||||
<Bold>{t('accessibility.reporting.address.line1')}</Bold>
|
||||
<br />
|
||||
{t('accessibility.reporting.address.line2')}
|
||||
<br />
|
||||
{t('accessibility.reporting.address.line3')}
|
||||
</P>
|
||||
|
||||
{/* Content reuse section */}
|
||||
<H lvl={2}>{t('reuse.title')}</H>
|
||||
<P>{t('reuse.body1')}</P>
|
||||
<P>{t('reuse.body2')}</P>
|
||||
|
||||
{/* Media subsection */}
|
||||
<H lvl={3} bold>
|
||||
{t('reuse.media.title')}
|
||||
</H>
|
||||
<P>
|
||||
{t('reuse.media.body').replace('visio@numerique.gouv.fr.', '')}
|
||||
<A href="mailto:visio@numerique.gouv.fr" color="primary">
|
||||
visio@numerique.gouv.fr
|
||||
</A>
|
||||
.
|
||||
</P>
|
||||
<H lvl={2}>{t('liability.title')}</H>
|
||||
<P>{t('liability.body1')}</P>
|
||||
<P>{t('liability.body2')}</P>
|
||||
<P>{t('liability.body3')}</P>
|
||||
<P>{t('liability.body4')}</P>
|
||||
<H lvl={2}>{t('dataProtection.title')}</H>
|
||||
<P>
|
||||
{t('dataProtection.body').replace('dpd@pm.gouv.fr', '')}{' '}
|
||||
<A href="mailto:dpd@pm.gouv.fr" color="primary">
|
||||
dpd@pm.gouv.fr
|
||||
</A>{' '}
|
||||
{t('dataProtection.body').split('dpd@pm.gouv.fr')[1]}
|
||||
</P>
|
||||
<P className={indentedStyle}>
|
||||
<Bold>{t('dataProtection.address.line1')}</Bold>
|
||||
<br />
|
||||
{t('dataProtection.address.line2')}
|
||||
<br />
|
||||
{t('dataProtection.address.line3')}
|
||||
<br />
|
||||
{t('dataProtection.address.line4')}
|
||||
</P>
|
||||
</HStack>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Bold, H, P, A, Ul } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
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('terms.title')}>
|
||||
<HStack display={'block'} padding={'2rem'}>
|
||||
<P>{t('terms.introduction')}</P>
|
||||
|
||||
{/* Article 1 */}
|
||||
<H lvl={2}>{t('terms.articles.article1.title')}</H>
|
||||
<P>{t('terms.articles.article1.content')}</P>
|
||||
|
||||
{/* Article 2 */}
|
||||
<H lvl={2}>{t('terms.articles.article2.title')}</H>
|
||||
<P>{t('terms.articles.article2.content')}</P>
|
||||
<Ul>
|
||||
{ensureArray(
|
||||
t('terms.articles.article2.purposes', { returnObjects: true })
|
||||
).map((purpose, index) => (
|
||||
<li key={index}>{purpose}</li>
|
||||
))}
|
||||
</Ul>
|
||||
|
||||
{/* Article 3 */}
|
||||
<H lvl={2}>{t('terms.articles.article3.title')}</H>
|
||||
{ensureArray(
|
||||
t('terms.articles.article3.definitions', { returnObjects: true })
|
||||
).map((def, index) => (
|
||||
<P key={index}>
|
||||
<Bold>{`"${def.term}"`}</Bold> {def.definition}
|
||||
</P>
|
||||
))}
|
||||
|
||||
{/* Article 4 */}
|
||||
<H lvl={2}>{t('terms.articles.article4.title')}</H>
|
||||
<P>{t('terms.articles.article4.content')}</P>
|
||||
|
||||
{/* Article 5 */}
|
||||
<H lvl={2}>{t('terms.articles.article5.title')}</H>
|
||||
|
||||
{/* Section 5.1 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article5.sections.section1.title')}
|
||||
</H>
|
||||
<P>{t('terms.articles.article5.sections.section1.content')}</P>
|
||||
<P>{t('terms.articles.article5.sections.section1.paragraph1')}</P>
|
||||
<P>{t('terms.articles.article5.sections.section1.paragraph2')}</P>
|
||||
<Ul>
|
||||
{ensureArray(
|
||||
t('terms.articles.article5.sections.section1.capabilities', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((capability, index) => (
|
||||
<li key={index}>{capability}</li>
|
||||
))}
|
||||
</Ul>
|
||||
<P
|
||||
style={{
|
||||
marginTop: '1.5rem',
|
||||
}}
|
||||
>
|
||||
{t('terms.articles.article5.sections.section1.paragraph3')}
|
||||
</P>
|
||||
|
||||
{/* Section 5.2 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article5.sections.section2.title')}
|
||||
</H>
|
||||
<P>{t('terms.articles.article5.sections.section2.content')}</P>
|
||||
<P>{t('terms.articles.article5.sections.section2.paragraph')}</P>
|
||||
<Ul>
|
||||
{ensureArray(
|
||||
t('terms.articles.article5.sections.section2.capabilities', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((capability, index) => (
|
||||
<li key={index}>{capability}</li>
|
||||
))}
|
||||
</Ul>
|
||||
|
||||
{/* Article 6 */}
|
||||
<H lvl={2}>{t('terms.articles.article6.title')}</H>
|
||||
|
||||
{/* Section 6.1 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article6.sections.section1.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('terms.articles.article6.sections.section1.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Section 6.2 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article6.sections.section2.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('terms.articles.article6.sections.section2.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Article 7 */}
|
||||
<H lvl={2}>{t('terms.articles.article7.title')}</H>
|
||||
|
||||
{/* Section 7.1 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article7.sections.section1.title')}
|
||||
</H>
|
||||
<P>{t('terms.articles.article7.sections.section1.content')}</P>
|
||||
|
||||
{/* Section 7.2 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article7.sections.section2.title')}
|
||||
</H>
|
||||
<P>{t('terms.articles.article7.sections.section2.content')}</P>
|
||||
|
||||
{ensureArray(
|
||||
t('terms.articles.article7.sections.section2.dataCategories', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((category, catIndex) => (
|
||||
<div key={catIndex}>
|
||||
<P>
|
||||
<Bold>{category.category}</Bold>
|
||||
</P>
|
||||
{category.items && (
|
||||
<Ul>
|
||||
{ensureArray(category.items).map((item, itemIndex) => (
|
||||
<li key={itemIndex}>{item}</li>
|
||||
))}
|
||||
</Ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Section 7.3 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article7.sections.section3.title')}
|
||||
</H>
|
||||
<P>{t('terms.articles.article7.sections.section3.content')}</P>
|
||||
<Ul>
|
||||
{ensureArray(
|
||||
t('terms.articles.article7.sections.section3.purposes', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((purpose, index) => (
|
||||
<li key={index}>{purpose}</li>
|
||||
))}
|
||||
</Ul>
|
||||
|
||||
{/* Section 7.4 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article7.sections.section4.title')}
|
||||
</H>
|
||||
<P>{t('terms.articles.article7.sections.section4.content')}</P>
|
||||
<Ul>
|
||||
{ensureArray(
|
||||
t('terms.articles.article7.sections.section4.bases', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((basis, index) => (
|
||||
<li key={index}>{basis}</li>
|
||||
))}
|
||||
</Ul>
|
||||
|
||||
{ensureArray(
|
||||
t('terms.articles.article7.sections.section4.details', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((detail, detailIndex) => (
|
||||
<div key={detailIndex}>
|
||||
<P>
|
||||
<Bold>{detail.title}</Bold>
|
||||
</P>
|
||||
<P>{detail.content}</P>
|
||||
<P>{detail.legalReference}</P>
|
||||
{detail.legalReferences && (
|
||||
<Ul>
|
||||
{ensureArray(detail.legalReferences).map((ref, refIndex) => (
|
||||
<li key={refIndex}>{ref}</li>
|
||||
))}
|
||||
</Ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Section 7.5 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article7.sections.section5.title')}
|
||||
</H>
|
||||
<P>{t('terms.articles.article7.sections.section5.content')}</P>
|
||||
|
||||
<table
|
||||
className={css({
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
border: '1px solid #ddd',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
border: '1px solid #ddd',
|
||||
textAlign: 'left',
|
||||
})}
|
||||
>
|
||||
{t('terms.articles.article7.sections.section5.dataType')}
|
||||
</th>
|
||||
<th
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
border: '1px solid #ddd',
|
||||
textAlign: 'left',
|
||||
})}
|
||||
>
|
||||
{t('terms.articles.article7.sections.section5.retentionPeriod')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ensureArray(
|
||||
t('terms.articles.article7.sections.section5.retentionTable', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
<td
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
border: '1px solid #ddd',
|
||||
})}
|
||||
>
|
||||
{row.dataType}
|
||||
</td>
|
||||
<td
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
border: '1px solid #ddd',
|
||||
})}
|
||||
>
|
||||
{row.retention}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<P>{t('terms.articles.article7.sections.section5.additionalInfo')}</P>
|
||||
|
||||
{/* Section 7.6 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article7.sections.section6.title')}
|
||||
</H>
|
||||
<P>{t('terms.articles.article7.sections.section6.content')}</P>
|
||||
|
||||
{/* Section 7.7 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article7.sections.section7.title')}
|
||||
</H>
|
||||
<P>{t('terms.articles.article7.sections.section7.content')}</P>
|
||||
<Ul
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
{ensureArray(
|
||||
t('terms.articles.article7.sections.section7.rights', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((right, index) => (
|
||||
<li key={index}>{right}</li>
|
||||
))}
|
||||
</Ul>
|
||||
|
||||
<P>
|
||||
{t(
|
||||
'terms.articles.article7.sections.section7.exerciseRights.content'
|
||||
)}
|
||||
</P>
|
||||
<Ul
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
{ensureArray(
|
||||
t(
|
||||
'terms.articles.article7.sections.section7.exerciseRights.methods',
|
||||
{ returnObjects: true }
|
||||
)
|
||||
).map((method, index) => {
|
||||
if (
|
||||
typeof method === 'string' &&
|
||||
method.includes('visio@numerique.gouv.fr')
|
||||
) {
|
||||
const parts = method.split('visio@numerique.gouv.fr')
|
||||
return (
|
||||
<li key={index}>
|
||||
{parts[0]}
|
||||
<A href="mailto:visio@numerique.gouv.fr" color="primary">
|
||||
visio@numerique.gouv.fr
|
||||
</A>
|
||||
{parts[1]}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
return <li key={index}>{method}</li>
|
||||
})}
|
||||
</Ul>
|
||||
|
||||
{ensureArray(
|
||||
t('terms.articles.article7.sections.section7.additionalInfo', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((info, index) => {
|
||||
if (typeof info === 'string' && info.includes('Cnil')) {
|
||||
const parts = info.split('Cnil')
|
||||
return (
|
||||
<P key={index}>
|
||||
{parts[0]}
|
||||
<A
|
||||
href="https://www.cnil.fr/fr/modele/courrier/sopposer-au-traitement-de-donnees"
|
||||
color="primary"
|
||||
>
|
||||
Cnil
|
||||
</A>
|
||||
{parts[1]}
|
||||
</P>
|
||||
)
|
||||
}
|
||||
return <P key={index}>{info}</P>
|
||||
})}
|
||||
|
||||
{/* Section 7.8 */}
|
||||
<H lvl={3} bold>
|
||||
{t('terms.articles.article7.sections.section8.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('terms.articles.article7.sections.section8.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
|
||||
import { LiveKitRoom } from '@livekit/components-react'
|
||||
import { Room, RoomOptions } from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
@@ -12,10 +12,11 @@ import { fetchRoom } from '../api/fetchRoom'
|
||||
import { ApiRoom } from '../api/ApiRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import posthog from 'posthog-js'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LocalUserChoices } from '../routes/Room'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -31,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,
|
||||
@@ -48,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,
|
||||
@@ -111,7 +113,13 @@ export const Conference = ({
|
||||
token={data?.livekit?.token}
|
||||
connect={true}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={userConfig.videoEnabled}
|
||||
video={
|
||||
userConfig.videoEnabled && {
|
||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
||||
userConfig.processorSerialized
|
||||
),
|
||||
}
|
||||
}
|
||||
connectOptions={connectOptions}
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
|
||||
@@ -1,30 +1,517 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
|
||||
import { usePreviewTracks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { CenteredContent } from '@/layout/CenteredContent'
|
||||
import { useUser } from '@/features/auth'
|
||||
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'
|
||||
import { Field } from '@/primitives/Field'
|
||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { LocalUserChoices } from '../routes/Room'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
import {
|
||||
EffectsConfiguration,
|
||||
EffectsConfigurationProps,
|
||||
} from '../livekit/components/effects/EffectsConfiguration'
|
||||
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)
|
||||
|
||||
const Effects = ({
|
||||
videoTrack,
|
||||
onSubmit,
|
||||
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const openDialog = () => setIsDialogOpen(true)
|
||||
|
||||
if (!BackgroundProcessorFactory.isSupported() || isMobileBrowser()) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
isOpen={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
size="large"
|
||||
>
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={css({
|
||||
textStyle: 'h1',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('title')}
|
||||
</Heading>
|
||||
<Text
|
||||
variant="subTitle"
|
||||
className={css({
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
{t('subTitle')}
|
||||
</Text>
|
||||
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
||||
</Dialog>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: '0',
|
||||
padding: '1rem',
|
||||
zIndex: '1',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="whiteCircle"
|
||||
onPress={openDialog}
|
||||
tooltip={t('description')}
|
||||
aria-label={t('description')}
|
||||
>
|
||||
<RiImageCircleAiFill size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: '20%',
|
||||
backgroundImage:
|
||||
'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(255,255,255,0) 100%)',
|
||||
borderBottomRadius: '1rem',
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Join = ({
|
||||
onSubmit,
|
||||
roomId,
|
||||
}: {
|
||||
onSubmit: (choices: LocalUserChoices) => void
|
||||
roomId: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { user } = useUser()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const {
|
||||
userChoices: initialUserChoices,
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
saveUsername,
|
||||
saveProcessorSerialized,
|
||||
} = usePersistentUserChoices({})
|
||||
|
||||
const [audioEnabled, setAudioEnabled] = useState(true)
|
||||
const [videoEnabled, setVideoEnabled] = useState(true)
|
||||
const [audioDeviceId, setAudioDeviceId] = useState<string>(
|
||||
initialUserChoices.audioDeviceId
|
||||
)
|
||||
const [videoDeviceId, setVideoDeviceId] = useState<string>(
|
||||
initialUserChoices.videoDeviceId
|
||||
)
|
||||
const [username, setUsername] = useState<string>(initialUserChoices.username)
|
||||
const [processor, setProcessor] = useState(
|
||||
BackgroundProcessorFactory.deserializeProcessor(
|
||||
initialUserChoices.processorSerialized
|
||||
)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
saveAudioInputDeviceId(audioDeviceId)
|
||||
}, [audioDeviceId, saveAudioInputDeviceId])
|
||||
|
||||
useEffect(() => {
|
||||
saveVideoInputDeviceId(videoDeviceId)
|
||||
}, [videoDeviceId, saveVideoInputDeviceId])
|
||||
|
||||
useEffect(() => {
|
||||
saveUsername(username)
|
||||
}, [username, saveUsername])
|
||||
|
||||
useEffect(() => {
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}, [
|
||||
processor,
|
||||
saveProcessorSerialized,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(processor?.serialize()),
|
||||
])
|
||||
|
||||
const tracks = usePreviewTracks(
|
||||
{
|
||||
audio: { deviceId: initialUserChoices.audioDeviceId },
|
||||
video: { deviceId: initialUserChoices.videoDeviceId },
|
||||
},
|
||||
onError
|
||||
)
|
||||
|
||||
const videoTrack = useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Video
|
||||
)[0] as LocalVideoTrack,
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const audioTrack = useMemo(
|
||||
() =>
|
||||
tracks?.filter(
|
||||
(track) => track.kind === Track.Kind.Audio
|
||||
)[0] as LocalVideoTrack,
|
||||
[tracks]
|
||||
)
|
||||
|
||||
const videoEl = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoEl.current as HTMLVideoElement | null
|
||||
|
||||
const handleVideoLoaded = () => {
|
||||
if (videoElement) {
|
||||
videoElement.style.opacity = '1'
|
||||
}
|
||||
}
|
||||
|
||||
if (videoElement && videoTrack && videoEnabled) {
|
||||
videoTrack.unmute()
|
||||
videoTrack.attach(videoElement)
|
||||
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
|
||||
}
|
||||
|
||||
return () => {
|
||||
videoTrack?.detach()
|
||||
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
|
||||
}
|
||||
}, [videoTrack, videoEnabled])
|
||||
|
||||
const enterRoom = useCallback(() => {
|
||||
onSubmit({
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
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.
|
||||
// So it's on purpose that processor is not included in the deps.
|
||||
// We just want to wait for the videoTrack to be loaded to apply the default processor.
|
||||
useEffect(() => {
|
||||
if (processor && videoTrack && !videoTrack.getProcessor()) {
|
||||
videoTrack.setProcessor(processor)
|
||||
}
|
||||
// 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 layout="centered" footer={false}>
|
||||
<CenteredContent title={t('join.heading')}>
|
||||
<PreJoin
|
||||
persistUserChoices
|
||||
onSubmit={onSubmit}
|
||||
micLabel={t('join.audioinput.label')}
|
||||
camLabel={t('join.videoinput.label')}
|
||||
joinLabel={t('join.joinLabel')}
|
||||
userLabel={t('join.usernameLabel')}
|
||||
defaults={{ username: user?.full_name }}
|
||||
/>
|
||||
</CenteredContent>
|
||||
<Screen footer={false}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
lg: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
height: 'auto',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2rem',
|
||||
padding: '0 2rem',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
lg: {
|
||||
flexDirection: 'row',
|
||||
width: 'auto',
|
||||
height: '570px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
lg: {
|
||||
width: '740px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
borderRadius: '1rem',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: 16 / 9,
|
||||
'--tw-shadow':
|
||||
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
|
||||
'--tw-shadow-colored':
|
||||
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
|
||||
boxShadow:
|
||||
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
|
||||
backgroundColor: 'black',
|
||||
})}
|
||||
>
|
||||
{videoTrack && videoEnabled ? (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
ref={videoEl}
|
||||
width="1280"
|
||||
height="720"
|
||||
className={css({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transform: 'rotateY(180deg)',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
borderRadius: '1rem',
|
||||
})}
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
fontWeight: '300',
|
||||
})}
|
||||
>
|
||||
{!videoEnabled && t('cameraDisabled')}
|
||||
{videoEnabled && !videoTrack && t('cameraStarting')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
|
||||
</div>
|
||||
<HStack justify="center" padding={1.5}>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
initialState={audioEnabled}
|
||||
track={audioTrack}
|
||||
initialDeviceId={initialUserChoices.audioDeviceId}
|
||||
onChange={(enabled) => setAudioEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
setAudioDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
initialState={videoEnabled}
|
||||
track={videoTrack}
|
||||
initialDeviceId={initialUserChoices.videoDeviceId}
|
||||
onChange={(enabled) => setVideoEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
setVideoDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
flexShrink: 0,
|
||||
padding: '0',
|
||||
sm: {
|
||||
width: '448px',
|
||||
padding: '0 3rem 9rem 3rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{renderWaitingState()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,107 @@
|
||||
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" 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="Type d'accès à la réunion"
|
||||
aria-label="Type d'accès à la réunion"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { LocalVideoTrack } from 'livekit-client'
|
||||
import { Text, P, ToggleButton, Div, H } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import {
|
||||
BackgroundBlurFactory,
|
||||
BackgroundBlurProcessorInterface,
|
||||
} from './blur/index'
|
||||
|
||||
const Information = styled('div', {
|
||||
base: {
|
||||
backgroundColor: 'orange.50',
|
||||
borderRadius: '4px',
|
||||
padding: '0.75rem 0.75rem',
|
||||
marginTop: '0.8rem',
|
||||
alignItems: 'start',
|
||||
},
|
||||
})
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
LIGHT = 5,
|
||||
NORMAL = 10,
|
||||
}
|
||||
|
||||
const isSupported = BackgroundBlurFactory.isSupported()
|
||||
|
||||
export const Effects = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
||||
const { isCameraEnabled, cameraTrack, localParticipant } =
|
||||
useLocalParticipant()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [processorPending, setProcessorPending] = useState(false)
|
||||
|
||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
||||
|
||||
const getProcessor = () => {
|
||||
return localCameraTrack?.getProcessor() as BackgroundBlurProcessorInterface
|
||||
}
|
||||
|
||||
const getBlurRadius = (): BlurRadius => {
|
||||
const processor = getProcessor()
|
||||
return processor?.options.blurRadius || BlurRadius.NONE
|
||||
}
|
||||
|
||||
const toggleBlur = async (blurRadius: number) => {
|
||||
if (!isCameraEnabled) await localParticipant.setCameraEnabled(true)
|
||||
if (!localCameraTrack) return
|
||||
|
||||
setProcessorPending(true)
|
||||
|
||||
const processor = getProcessor()
|
||||
const currentBlurRadius = getBlurRadius()
|
||||
|
||||
try {
|
||||
if (blurRadius == currentBlurRadius && processor) {
|
||||
await localCameraTrack.stopProcessor()
|
||||
} else if (!processor) {
|
||||
await localCameraTrack.setProcessor(
|
||||
BackgroundBlurFactory.getProcessor({ blurRadius })!
|
||||
)
|
||||
} else {
|
||||
processor?.update({ blurRadius })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error applying blur:', error)
|
||||
} finally {
|
||||
setProcessorPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
if (!videoElement) return
|
||||
|
||||
const attachVideoTrack = async () => localCameraTrack?.attach(videoElement)
|
||||
attachVideoTrack()
|
||||
|
||||
return () => {
|
||||
if (!videoElement) return
|
||||
localCameraTrack.detach(videoElement)
|
||||
}
|
||||
}, [localCameraTrack, isCameraEnabled])
|
||||
|
||||
const isSelected = (blurRadius: BlurRadius) => {
|
||||
return isCameraEnabled && getBlurRadius() == blurRadius
|
||||
}
|
||||
|
||||
const tooltipLabel = (blurRadius: BlurRadius) => {
|
||||
return t(`blur.${isSelected(blurRadius) ? 'clear' : 'apply'}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack padding="0 1.5rem" overflowY="scroll">
|
||||
{localCameraTrack && isCameraEnabled ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
width="100%"
|
||||
muted
|
||||
style={{
|
||||
transform: 'rotateY(180deg)',
|
||||
minHeight: '175px',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '174px',
|
||||
display: 'flex',
|
||||
backgroundColor: 'black',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<P
|
||||
style={{
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
textWrap: 'balance',
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
{t('activateCamera')}
|
||||
</P>
|
||||
</div>
|
||||
)}
|
||||
<Div
|
||||
alignItems={'left'}
|
||||
width={'100%'}
|
||||
style={{
|
||||
border: '1px solid #dadce0',
|
||||
borderRadius: '8px',
|
||||
margin: '0 .625rem',
|
||||
padding: '0.5rem 1rem',
|
||||
}}
|
||||
>
|
||||
<H
|
||||
lvl={3}
|
||||
style={{
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{t('heading')}
|
||||
</H>
|
||||
{isSupported ? (
|
||||
<HStack>
|
||||
<ToggleButton
|
||||
size={'sm'}
|
||||
aria-label={tooltipLabel(BlurRadius.LIGHT)}
|
||||
tooltip={tooltipLabel(BlurRadius.LIGHT)}
|
||||
isDisabled={processorPending}
|
||||
onPress={async () => await toggleBlur(BlurRadius.LIGHT)}
|
||||
isSelected={isSelected(BlurRadius.LIGHT)}
|
||||
>
|
||||
{t('blur.light')}
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
size={'sm'}
|
||||
aria-label={tooltipLabel(BlurRadius.NORMAL)}
|
||||
tooltip={tooltipLabel(BlurRadius.NORMAL)}
|
||||
isDisabled={processorPending}
|
||||
onPress={async () => await toggleBlur(BlurRadius.NORMAL)}
|
||||
isSelected={isSelected(BlurRadius.NORMAL)}
|
||||
>
|
||||
{t('blur.normal')}
|
||||
</ToggleButton>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text variant="sm">{t('notAvailable')}</Text>
|
||||
)}
|
||||
<Information>
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
⚠︎ {t('experimental')}
|
||||
</Text>
|
||||
</Information>
|
||||
</Div>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||