mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
70 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 |
@@ -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]
|
||||
@@ -238,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
|
||||
|
||||
@@ -79,3 +79,6 @@ db.sqlite3
|
||||
|
||||
# Egress output
|
||||
docker/livekit/out
|
||||
|
||||
# LiveKit CA configuration
|
||||
docker/livekit/rootCA.pem
|
||||
|
||||
@@ -45,6 +45,23 @@ docker_build(
|
||||
)
|
||||
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 .'))
|
||||
|
||||
migration = '''
|
||||
|
||||
@@ -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}_*") == []
|
||||
+4
-8
@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
import brevo_python
|
||||
import pytest
|
||||
|
||||
from core.services.marketing_service import (
|
||||
from core.services.marketing import (
|
||||
BrevoMarketingService,
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
@@ -144,9 +144,7 @@ def clear_marketing_cache():
|
||||
def test_get_marketing_service_caching(clear_marketing_cache):
|
||||
"""Test marketing service caching behavior."""
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = (
|
||||
"core.services.marketing_service.BrevoMarketingService"
|
||||
)
|
||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
@@ -163,14 +161,12 @@ def test_get_marketing_service_invalid_class(clear_marketing_cache):
|
||||
get_marketing_service()
|
||||
|
||||
|
||||
@mock.patch("core.services.marketing_service.import_string")
|
||||
@mock.patch("core.services.marketing.import_string")
|
||||
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
|
||||
"""Test service class is instantiated only once."""
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = (
|
||||
"core.services.marketing_service.BrevoMarketingService"
|
||||
)
|
||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
mock_service_cls = mock.Mock()
|
||||
@@ -16,7 +16,7 @@ from ..factories import (
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from ..models import ResourceAccess, RoleChoices
|
||||
from ..models import ResourceAccess, RoleChoices, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -44,13 +44,13 @@ def test_api_room_user_accesses_list_authenticated_not_related():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True)
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
UserResourceAccessFactory(resource=public_room)
|
||||
UserResourceAccessFactory(resource=public_room, role="member")
|
||||
UserResourceAccessFactory(resource=public_room, role="administrator")
|
||||
UserResourceAccessFactory(resource=public_room, role="owner")
|
||||
|
||||
private_room = RoomFactory(is_public=False)
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
UserResourceAccessFactory(resource=private_room)
|
||||
UserResourceAccessFactory(resource=private_room, role="member")
|
||||
UserResourceAccessFactory(resource=private_room, role="administrator")
|
||||
@@ -73,13 +73,17 @@ def test_api_room_user_accesses_list_authenticated_member():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True, users=[(user, "member")])
|
||||
public_room = RoomFactory(
|
||||
access_level=RoomAccessLevel.PUBLIC, users=[(user, "member")]
|
||||
)
|
||||
UserResourceAccessFactory(resource=public_room)
|
||||
UserResourceAccessFactory(resource=public_room, role="member")
|
||||
UserResourceAccessFactory(resource=public_room, role="administrator")
|
||||
UserResourceAccessFactory(resource=public_room, role="owner")
|
||||
|
||||
private_room = RoomFactory(is_public=False, users=[(user, "member")])
|
||||
private_room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, users=[(user, "member")]
|
||||
)
|
||||
UserResourceAccessFactory(resource=private_room)
|
||||
UserResourceAccessFactory(resource=private_room, role="member")
|
||||
UserResourceAccessFactory(resource=private_room, role="administrator")
|
||||
@@ -102,7 +106,7 @@ def test_api_room_user_accesses_list_authenticated_administrator():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True)
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
public_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(
|
||||
@@ -115,7 +119,7 @@ def test_api_room_user_accesses_list_authenticated_administrator():
|
||||
UserResourceAccessFactory(resource=public_room, role="owner"),
|
||||
)
|
||||
|
||||
private_room = RoomFactory(is_public=False)
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
private_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(
|
||||
@@ -148,7 +152,7 @@ def test_api_room_user_accesses_list_authenticated_owner():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True)
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
public_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(resource=public_room, user=user, role="owner"),
|
||||
@@ -158,7 +162,7 @@ def test_api_room_user_accesses_list_authenticated_owner():
|
||||
UserResourceAccessFactory(resource=public_room, role="administrator"),
|
||||
UserResourceAccessFactory(resource=public_room, role="owner"),
|
||||
)
|
||||
private_room = RoomFactory(is_public=False)
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
private_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(resource=private_room, user=user, role="owner"),
|
||||
@@ -252,8 +256,8 @@ def test_api_room_user_accesses_retrieve_authenticated_not_related():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
room = RoomFactory(is_public=is_public)
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(access_level=access_level)
|
||||
assert len(RoleChoices.choices) == 3
|
||||
|
||||
for role, _name in RoleChoices.choices:
|
||||
@@ -277,9 +281,9 @@ def test_api_room_user_accesses_retrieve_authenticated_member():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(
|
||||
is_public=is_public,
|
||||
access_level=access_level,
|
||||
users=[(user, "member")],
|
||||
)
|
||||
assert len(RoleChoices.choices) == 3
|
||||
@@ -305,8 +309,8 @@ def test_api_room_user_accesses_retrieve_authenticated_administrator():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
room = RoomFactory(is_public=is_public, users=[(user, "administrator")])
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(access_level=access_level, users=[(user, "administrator")])
|
||||
assert len(RoleChoices.choices) == 3
|
||||
|
||||
for role, _name in RoleChoices.choices:
|
||||
@@ -334,8 +338,8 @@ def test_api_room_user_accesses_retrieve_authenticated_owner():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
room = RoomFactory(is_public=is_public, users=[(user, "owner")])
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(access_level=access_level, users=[(user, "owner")])
|
||||
assert len(RoleChoices.choices) == 3
|
||||
|
||||
for role, _name in RoleChoices.choices:
|
||||
|
||||
@@ -22,10 +22,32 @@ def test_api_users_list_anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_list_authenticated():
|
||||
def test_api_users_list_authenticated_secure(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users.
|
||||
Authenticated users should not be able to list any user
|
||||
when ALLOW_UNSECURE_USER_LISTING is False.
|
||||
"""
|
||||
settings.ALLOW_UNSECURE_USER_LISTING = False
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 0
|
||||
|
||||
|
||||
def test_api_users_list_authenticated_unsecure(settings):
|
||||
"""
|
||||
Authenticated users should be able to list all users
|
||||
when ALLOW_UNSECURE_USER_LISTING is True.
|
||||
"""
|
||||
settings.ALLOW_UNSECURE_USER_LISTING = True
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -40,11 +62,12 @@ def test_api_users_list_authenticated():
|
||||
assert len(content["results"]) == 3
|
||||
|
||||
|
||||
def test_api_users_list_query_email():
|
||||
def test_api_users_list_query_email(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users
|
||||
and filter by email.
|
||||
"""
|
||||
settings.ALLOW_UNSECURE_USER_LISTING = True
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
|
||||
import pytest
|
||||
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
from core.models import Room
|
||||
from core.models import Room, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -80,10 +80,10 @@ def test_models_rooms_users():
|
||||
assert list(room.users.all()) == [user]
|
||||
|
||||
|
||||
def test_models_rooms_is_public_default():
|
||||
def test_models_rooms_access_level_default():
|
||||
"""A room should be public by default."""
|
||||
room = Room.objects.create(name="room")
|
||||
assert room.is_public is True
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
# Access rights methods
|
||||
@@ -164,3 +164,14 @@ def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
|
||||
assert room.is_administrator(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is True
|
||||
|
||||
|
||||
def test_models_rooms_is_public_property():
|
||||
"""Test the is_public property returns correctly based on access_level."""
|
||||
# Test public room
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
assert public_room.is_public is True
|
||||
|
||||
# Test non-public room
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
assert private_room.is_public is False
|
||||
|
||||
@@ -37,7 +37,9 @@ def generate_color(identity: str) -> str:
|
||||
return f"hsl({hue}, {saturation}%, {lightness}%)"
|
||||
|
||||
|
||||
def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
def generate_token(
|
||||
room: str, user, username: Optional[str] = None, color: Optional[str] = None
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
Args:
|
||||
@@ -45,6 +47,8 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
user (User): The user which request the access token.
|
||||
username (Optional[str]): The username to be displayed in the room.
|
||||
If none, a default value will be used.
|
||||
color (Optional[str]): The color to be displayed in the room.
|
||||
If none, a value will be generated
|
||||
|
||||
Returns:
|
||||
str: The LiveKit JWT access token.
|
||||
@@ -69,6 +73,9 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
identity = str(user.sub)
|
||||
default_username = str(user)
|
||||
|
||||
if color is None:
|
||||
color = generate_color(identity)
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
@@ -77,7 +84,29 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_metadata(json.dumps({"color": generate_color(identity)}))
|
||||
.with_metadata(json.dumps({"color": color}))
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
def generate_livekit_config(
|
||||
room_id: str, user, username: str, color: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
Args:
|
||||
room_id: Room identifier
|
||||
user: User instance requesting access
|
||||
username: Display name in room
|
||||
|
||||
Returns:
|
||||
dict: LiveKit configuration with URL, room and access token
|
||||
"""
|
||||
return {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": room_id,
|
||||
"token": generate_token(
|
||||
room=room_id, user=user, username=username, color=color
|
||||
),
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ class Base(Configuration):
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = values.Value(None)
|
||||
SILENCED_SYSTEM_CHECKS = values.ListValue([])
|
||||
ALLOW_UNSECURE_USER_LISTING = values.BooleanValue(
|
||||
False, environ_name="ALLOW_UNSECURE_USER_LISTING", environ_prefix=None
|
||||
)
|
||||
|
||||
# Application definition
|
||||
ROOT_URLCONF = "meet.urls"
|
||||
@@ -235,7 +238,17 @@ class Base(Configuration):
|
||||
|
||||
# Cache
|
||||
CACHES = {
|
||||
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/1",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
@@ -252,6 +265,13 @@ class Base(Configuration):
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"request_entry": values.Value(
|
||||
default="150/minute",
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
@@ -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):
|
||||
|
||||
+18
-18
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.14"
|
||||
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.6",
|
||||
"boto3==1.37.4",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-storages[s3]==1.14.5",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.5",
|
||||
"django==5.1.7",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.23.0",
|
||||
"june-analytics-python==2.3.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.4",
|
||||
"psycopg[binary]==3.2.5",
|
||||
"PyJWT==2.10.1",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.20.0",
|
||||
"sentry-sdk==2.22.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.8.2",
|
||||
"whitenoise==6.9.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.8.1",
|
||||
"aiohttp==3.11.11",
|
||||
"livekit-api==0.8.2",
|
||||
"aiohttp==3.11.13",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -70,21 +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.3",
|
||||
"types-requests==2.32.0.20241016",
|
||||
"ruff==0.9.9",
|
||||
"types-requests==2.32.0.20250301",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
|
||||
"output": "src/locales/$LOCALE/$NAMESPACE.json",
|
||||
"createOldCatalogs": false,
|
||||
"locales": ["en", "fr", "de"],
|
||||
"locales": ["en", "fr", "de", "nl"],
|
||||
"sort": true
|
||||
}
|
||||
|
||||
Generated
+660
-617
File diff suppressed because it is too large
Load Diff
+22
-22
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -16,45 +16,45 @@
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.3",
|
||||
"@pandacss/preset-panda": "0.51.1",
|
||||
"@pandacss/preset-panda": "0.53.0",
|
||||
"@react-aria/toast": "3.0.0-beta.19",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.64.2",
|
||||
"@tanstack/react-query": "5.67.1",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "24.2.2",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"i18next-parser": "9.1.0",
|
||||
"i18next-browser-languagedetector": "8.0.4",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.8.1",
|
||||
"posthog-js": "1.210.2",
|
||||
"livekit-client": "2.9.5",
|
||||
"posthog-js": "1.225.1",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.6.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "4.0.3",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.1.3",
|
||||
"wouter": "3.4.0"
|
||||
"wouter": "3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.51.1",
|
||||
"@tanstack/eslint-plugin-query": "5.64.2",
|
||||
"@tanstack/react-query-devtools": "5.64.2",
|
||||
"@types/node": "22.10.10",
|
||||
"@pandacss/dev": "0.53.0",
|
||||
"@tanstack/eslint-plugin-query": "5.66.1",
|
||||
"@tanstack/react-query-devtools": "5.67.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.21.0",
|
||||
"@typescript-eslint/parser": "8.21.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.26.0",
|
||||
"@typescript-eslint/parser": "8.26.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "10.0.1",
|
||||
"eslint-config-prettier": "10.0.2",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.1.0",
|
||||
"eslint-plugin-react-refresh": "0.4.18",
|
||||
"postcss": "8.5.1",
|
||||
"prettier": "3.4.2",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.0.11",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.19",
|
||||
"postcss": "8.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.0",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
+35
-27
@@ -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'
|
||||
@@ -15,39 +15,47 @@ 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 />
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Switch>
|
||||
<Route path="/sdk" nest>
|
||||
<Route path="/create-button">
|
||||
<SdkCreateButton />
|
||||
</Route>
|
||||
</Route>
|
||||
{/* We only want support and ReactQueryDevTools in non /sdk routes */}
|
||||
<Route path="*">
|
||||
<AppInitialization />
|
||||
<Layout>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
component={route.Component}
|
||||
/>
|
||||
))}
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="top-left"
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-left" />
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -2,4 +2,6 @@ export const keys = {
|
||||
user: 'user',
|
||||
room: 'room',
|
||||
config: 'config',
|
||||
requestEntry: 'requestEntry',
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,15 @@ 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
|
||||
@@ -30,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
|
||||
@@ -38,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'
|
||||
|
||||
@@ -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,18 +1,24 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Div } from '@/primitives'
|
||||
import { NotificationType } from './NotificationType'
|
||||
import { NotificationDuration } from './NotificationDuration'
|
||||
import { Div } from '@/primitives'
|
||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
import { decodeNotificationDataReceived } from './utils'
|
||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
@@ -44,7 +50,7 @@ export const MainNotificationToast = () => {
|
||||
}, [room, triggerNotificationSound])
|
||||
|
||||
const handleEmoji = (emoji: string, participant: Participant) => {
|
||||
if (!emoji) return
|
||||
if (!emoji || !EMOJIS.includes(emoji)) return
|
||||
const id = instanceIdRef.current++
|
||||
setReactions((prev) => [
|
||||
...prev,
|
||||
@@ -64,14 +70,11 @@ export const MainNotificationToast = () => {
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant
|
||||
) => {
|
||||
const decoder = new TextDecoder()
|
||||
const notificationPayload = JSON.parse(decoder.decode(payload))
|
||||
const notificationType = notificationPayload.type
|
||||
const data = notificationPayload.data
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
|
||||
if (!participant) return
|
||||
if (!participant || !notification) return
|
||||
|
||||
switch (notificationType) {
|
||||
switch (notification.type) {
|
||||
case NotificationType.ParticipantMuted:
|
||||
toastQueue.add(
|
||||
{
|
||||
@@ -82,7 +85,8 @@ export const MainNotificationToast = () => {
|
||||
)
|
||||
break
|
||||
case NotificationType.ReactionReceived:
|
||||
handleEmoji(data?.emoji, participant)
|
||||
if (notification.data?.emoji)
|
||||
handleEmoji(notification.data.emoji, participant)
|
||||
break
|
||||
default:
|
||||
return
|
||||
@@ -142,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) =>
|
||||
@@ -194,6 +198,7 @@ export const MainNotificationToast = () => {
|
||||
return (
|
||||
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
||||
<ToastProvider />
|
||||
<WaitingParticipantNotification />
|
||||
<ReactionPortals reactions={reactions} />
|
||||
</Div>
|
||||
)
|
||||
|
||||
@@ -5,4 +5,5 @@ export enum NotificationType {
|
||||
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',
|
||||
|
||||
@@ -40,9 +40,9 @@ export function ToastMessageReceived({ state, ...props }: ToastProps) {
|
||||
padding: '14px',
|
||||
gap: '0.75rem',
|
||||
textAlign: 'start',
|
||||
width: '150px',
|
||||
width: '250px',
|
||||
md: {
|
||||
width: '260px',
|
||||
width: '350px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import { StyledToastContainer } from './Toast'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiInfinityLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { usePrevious } from '@/hooks/usePrevious'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
|
||||
export const NOTIFICATION_DISPLAY_DURATION = 10000
|
||||
|
||||
export const WaitingParticipantNotification = () => {
|
||||
const { t } = useTranslation('notifications', {
|
||||
keyPrefix: 'waitingParticipants',
|
||||
})
|
||||
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
const [showQuickActionsMessage, setShowQuickActionsMessage] = useState(false)
|
||||
const { waitingParticipants, handleParticipantEntry } =
|
||||
useWaitingParticipants()
|
||||
const prevWaitingParticipant = usePrevious<WaitingParticipant[] | undefined>(
|
||||
waitingParticipants
|
||||
)
|
||||
|
||||
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
||||
|
||||
useEffect(() => {
|
||||
// Show notification when the first participant enters the waiting room
|
||||
if (
|
||||
!isParticipantListEmpty(waitingParticipants) &&
|
||||
isParticipantListEmpty(prevWaitingParticipant) &&
|
||||
!isParticipantsOpen
|
||||
) {
|
||||
setShowQuickActionsMessage(true)
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowQuickActionsMessage(false)
|
||||
timerRef.current = null // Clear the ref when timeout completes
|
||||
}, NOTIFICATION_DISPLAY_DURATION)
|
||||
} else if (waitingParticipants.length !== prevWaitingParticipant?.length) {
|
||||
// Hide notification when the participant count changes
|
||||
setShowQuickActionsMessage(false)
|
||||
}
|
||||
}, [waitingParticipants, prevWaitingParticipant, isParticipantsOpen])
|
||||
|
||||
useEffect(() => {
|
||||
// This cleanup function will only run when the component unmounts
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Hide notification when participants panel is opened
|
||||
if (isParticipantsOpen) {
|
||||
setShowQuickActionsMessage(false)
|
||||
}
|
||||
}, [isParticipantsOpen])
|
||||
|
||||
if (!waitingParticipants.length) return null
|
||||
|
||||
return (
|
||||
<StyledToastContainer role="alert">
|
||||
<HStack
|
||||
padding={'1rem'}
|
||||
gap={'1rem'}
|
||||
role={'alertdialog'}
|
||||
aria-label={waitingParticipants.length > 1 ? t('several') : t('one')}
|
||||
aria-modal={false}
|
||||
>
|
||||
{showQuickActionsMessage ? (
|
||||
<VStack gap={'1rem'} alignItems={'start'}>
|
||||
<Text
|
||||
variant="paragraph"
|
||||
margin={false}
|
||||
style={{
|
||||
minWidth: '15rem',
|
||||
}}
|
||||
>
|
||||
{t('one')}
|
||||
</Text>
|
||||
<HStack gap="1rem">
|
||||
<Avatar
|
||||
name={waitingParticipants[0].username}
|
||||
bgColor={waitingParticipants[0].color}
|
||||
context="list"
|
||||
notification
|
||||
/>
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
maxWidth: '10rem',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
})}
|
||||
>
|
||||
{waitingParticipants[0].username}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack gap="0.25rem" marginLeft="auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
style={{
|
||||
color: '#60a5fa',
|
||||
}}
|
||||
onPress={async () => {
|
||||
await handleParticipantEntry(waitingParticipants[0], true)
|
||||
setShowQuickActionsMessage(false)
|
||||
}}
|
||||
>
|
||||
{t('accept')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
style={{
|
||||
color: '#60a5fa',
|
||||
}}
|
||||
onPress={() => {
|
||||
toggleParticipants()
|
||||
setShowQuickActionsMessage(false)
|
||||
}}
|
||||
>
|
||||
{t('open')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<>
|
||||
<HStack gap={0}>
|
||||
<Avatar
|
||||
name={waitingParticipants[0].username}
|
||||
bgColor={waitingParticipants[0].color}
|
||||
context="list"
|
||||
notification
|
||||
/>
|
||||
{waitingParticipants.length > 1 && (
|
||||
<Avatar
|
||||
name={waitingParticipants[1].username}
|
||||
bgColor={waitingParticipants[1].color}
|
||||
context="list"
|
||||
notification
|
||||
style={{
|
||||
marginLeft: '-10px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{waitingParticipants.length > 2 && (
|
||||
<span
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '1rem',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
borderRadius: '50%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: 'primaryDark.100',
|
||||
border: '2px solid white',
|
||||
marginLeft: '-10px',
|
||||
})}
|
||||
>
|
||||
{waitingParticipants.length < 102 ? (
|
||||
<p>+{waitingParticipants.length - 2}</p>
|
||||
) : (
|
||||
<RiInfinityLine size={20} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</HStack>
|
||||
<Text
|
||||
variant="paragraph"
|
||||
margin={false}
|
||||
wrap={'balance'}
|
||||
style={{
|
||||
maxWidth: waitingParticipants.length == 1 ? '10rem' : '15rem',
|
||||
}}
|
||||
>
|
||||
{waitingParticipants.length > 1 ? t('several') : t('one')}
|
||||
</Text>
|
||||
{!isParticipantsOpen && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
style={{
|
||||
color: '#60a5fa',
|
||||
}}
|
||||
onPress={() => {
|
||||
toggleParticipants()
|
||||
}}
|
||||
>
|
||||
{t('open')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -26,3 +27,25 @@ export const closeLowerHandToasts = () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { usePreviewTracks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useMemo, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
||||
@@ -19,6 +19,15 @@ import {
|
||||
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useLobby } from '../hooks/useLobby'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
|
||||
const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
@@ -99,8 +108,10 @@ const Effects = ({
|
||||
|
||||
export const Join = ({
|
||||
onSubmit,
|
||||
roomId,
|
||||
}: {
|
||||
onSubmit: (choices: LocalUserChoices) => void
|
||||
roomId: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
@@ -195,7 +206,7 @@ export const Join = ({
|
||||
}
|
||||
}, [videoTrack, videoEnabled])
|
||||
|
||||
function handleSubmit() {
|
||||
const enterRoom = useCallback(() => {
|
||||
onSubmit({
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
@@ -204,6 +215,72 @@ export const Join = ({
|
||||
username,
|
||||
processorSerialized: processor?.serialize(),
|
||||
})
|
||||
}, [
|
||||
onSubmit,
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
username,
|
||||
processor,
|
||||
])
|
||||
|
||||
// Room data strategy:
|
||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
||||
// 3. State is manually updated via queryClient when a waiting participant is accepted
|
||||
// 4. No automatic refetching or revalidation occurs during this period
|
||||
// todo - refactor in a hook
|
||||
const {
|
||||
data: roomData,
|
||||
error,
|
||||
isError,
|
||||
refetch: refetchRoom,
|
||||
} = useQuery({
|
||||
/* eslint-disable @tanstack/query/exhaustive-deps */
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId, username }),
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
retry: false,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isError && error?.statusCode == 404) {
|
||||
// The room component will handle the room creation if the user is authenticated
|
||||
enterRoom()
|
||||
}
|
||||
}, [isError, error, enterRoom])
|
||||
|
||||
const handleAccepted = (response: ApiRequestEntry) => {
|
||||
queryClient.setQueryData([keys.room, roomId], {
|
||||
...roomData,
|
||||
livekit: response.livekit,
|
||||
})
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
const { status, startWaiting } = useLobby({
|
||||
roomId,
|
||||
username,
|
||||
onAccepted: handleAccepted,
|
||||
})
|
||||
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { data } = await refetchRoom()
|
||||
|
||||
if (!data?.livekit) {
|
||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
||||
if (data?.access_level == ApiAccessLevel.TRUSTED) {
|
||||
openLoginHint()
|
||||
}
|
||||
startWaiting()
|
||||
return
|
||||
}
|
||||
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
// This hook is used to setup the persisted user choice processor on initialization.
|
||||
@@ -216,6 +293,84 @@ export const Join = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoTrack])
|
||||
|
||||
const renderWaitingState = () => {
|
||||
switch (status) {
|
||||
case ApiLobbyStatus.TIMEOUT:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false}>
|
||||
{t('timeoutInvite.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('timeoutInvite.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
|
||||
case ApiLobbyStatus.DENIED:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false}>
|
||||
{t('denied.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('denied.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
|
||||
case ApiLobbyStatus.WAITING:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false}>
|
||||
{t('waiting.title')}
|
||||
</H>
|
||||
<Text
|
||||
as="p"
|
||||
variant="note"
|
||||
className={css({ marginBottom: '1.5rem' })}
|
||||
>
|
||||
{t('waiting.body')}
|
||||
</Text>
|
||||
<Spinner />
|
||||
</VStack>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={t('joinLabel')}
|
||||
submitButtonProps={{
|
||||
fullWidth: true,
|
||||
}}
|
||||
>
|
||||
<VStack marginBottom={1}>
|
||||
<H lvl={1} margin="sm">
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="text"
|
||||
onChange={setUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={initialUserChoices?.username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
maxLength={50}
|
||||
/>
|
||||
</VStack>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen footer={false}>
|
||||
<div
|
||||
@@ -353,35 +508,7 @@ export const Join = ({
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={t('joinLabel')}
|
||||
submitButtonProps={{
|
||||
fullWidth: true,
|
||||
}}
|
||||
>
|
||||
<VStack marginBottom={1}>
|
||||
<H lvl={1} margin={false}>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="text"
|
||||
onChange={setUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={initialUserChoices?.username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
maxLength={50}
|
||||
/>
|
||||
</VStack>
|
||||
</Form>
|
||||
{renderWaitingState()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
@@ -237,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) {
|
||||
@@ -251,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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { layoutStore } from '@/stores/layout'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { text } from '@/primitives/Text'
|
||||
import { Box, Button, Div } from '@/primitives'
|
||||
import { Button, Div } from '@/primitives'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
@@ -11,6 +11,7 @@ import { ReactNode } from 'react'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
import { Transcript } from './Transcript'
|
||||
import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -27,9 +28,16 @@ const StyledSidePanel = ({
|
||||
isClosed,
|
||||
closeButtonTooltip,
|
||||
}: StyledSidePanelProps) => (
|
||||
<Box
|
||||
size="sm"
|
||||
<div
|
||||
className={css({
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'box.border',
|
||||
backgroundColor: 'box.bg',
|
||||
color: 'box.text',
|
||||
borderRadius: 8,
|
||||
flex: 1,
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -40,7 +48,6 @@ const StyledSidePanel = ({
|
||||
top: 0,
|
||||
bottom: '80px',
|
||||
width: '360px',
|
||||
position: 'absolute',
|
||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||
})}
|
||||
style={{
|
||||
@@ -79,7 +86,7 @@ const StyledSidePanel = ({
|
||||
</Button>
|
||||
</Div>
|
||||
{children}
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
|
||||
type PanelProps = {
|
||||
@@ -108,6 +115,7 @@ export const SidePanel = () => {
|
||||
isChatOpen,
|
||||
isSidePanelOpen,
|
||||
isTranscriptOpen,
|
||||
isAdminOpen,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
@@ -132,6 +140,9 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isTranscriptOpen}>
|
||||
<Transcript />
|
||||
</Panel>
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
<Admin />
|
||||
</Panel>
|
||||
</StyledSidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,11 @@ export const Transcript = () => {
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<div className={css({ height: '2rem' })} />
|
||||
<Button isDisabled={isLoading} onPress={() => handleTranscript()}>
|
||||
<Button
|
||||
isDisabled={isLoading}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="stop-transcript"
|
||||
>
|
||||
<RiStopCircleLine style={{ marginRight: '0.5rem' }} />{' '}
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
@@ -89,7 +93,11 @@ export const Transcript = () => {
|
||||
{t('start.body')}
|
||||
</Text>
|
||||
<div className={css({ height: '2rem' })} />
|
||||
<Button isDisabled={isLoading} onPress={() => handleTranscript()}>
|
||||
<Button
|
||||
isDisabled={isLoading}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="start-transcript"
|
||||
>
|
||||
<RiRecordCircleLine style={{ marginRight: '0.5rem' }} />{' '}
|
||||
{t('start.button')}
|
||||
</Button>
|
||||
|
||||
@@ -19,7 +19,6 @@ export const ChatEntry: (
|
||||
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
|
||||
ref
|
||||
) {
|
||||
// Fixme - Livekit messageFormatter strips '\n' char
|
||||
const formattedMessage = React.useMemo(() => {
|
||||
return messageFormatter ? messageFormatter(entry.message) : entry.message
|
||||
}, [entry.message, messageFormatter])
|
||||
@@ -58,6 +57,7 @@ export const ChatEntry: (
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
whiteSpace: 'pre-wrap',
|
||||
'& .lk-chat-link': {
|
||||
color: 'blue',
|
||||
textDecoration: 'underline',
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ChatToggle = ({
|
||||
right: '-.25rem',
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
backgroundColor: 'red',
|
||||
backgroundColor: '#E1000F',
|
||||
borderRadius: '50%',
|
||||
zIndex: 1,
|
||||
border: '2px solid #d1d5db',
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMore2Line } from '@remixicon/react'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { OptionsMenuItems } from '@/features/rooms/livekit/components/controls/Options/OptionsMenuItems'
|
||||
import { OptionsMenuItems } from './OptionsMenuItems'
|
||||
|
||||
export const OptionsButton = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
@@ -15,7 +15,7 @@ export const OptionsButton = () => {
|
||||
aria-label={t('options.buttonLabel')}
|
||||
tooltip={t('options.buttonLabel')}
|
||||
>
|
||||
<RiMore2Line />
|
||||
<RiMoreFill />
|
||||
</Button>
|
||||
<OptionsMenuItems />
|
||||
</Menu>
|
||||
|
||||
+5
-6
@@ -3,7 +3,6 @@ import { css } from '@/styled-system/css'
|
||||
import { ToggleButton } from 'react-aria-components'
|
||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import { RiArrowUpSLine } from '@remixicon/react'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ToggleHeader = styled(ToggleButton, {
|
||||
@@ -46,19 +45,19 @@ const ListContainer = styled(VStack, {
|
||||
},
|
||||
})
|
||||
|
||||
type ParticipantsCollapsableListProps = {
|
||||
export type ParticipantsCollapsableListProps<T> = {
|
||||
heading: string
|
||||
participants: Array<Participant>
|
||||
renderParticipant: (participant: Participant) => JSX.Element
|
||||
participants: Array<T>
|
||||
renderParticipant: (participant: T) => JSX.Element
|
||||
action?: () => JSX.Element
|
||||
}
|
||||
|
||||
export const ParticipantsCollapsableList = ({
|
||||
export function ParticipantsCollapsableList<T>({
|
||||
heading,
|
||||
participants,
|
||||
renderParticipant,
|
||||
action,
|
||||
}: ParticipantsCollapsableListProps) => {
|
||||
}: ParticipantsCollapsableListProps<T>) {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [isOpen, setIsOpen] = useState(true)
|
||||
const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, {
|
||||
|
||||
+27
-3
@@ -7,6 +7,11 @@ import { ParticipantListItem } from '../../controls/Participants/ParticipantList
|
||||
import { ParticipantsCollapsableList } from '../../controls/Participants/ParticipantsCollapsableList'
|
||||
import { HandRaisedListItem } from '../../controls/Participants/HandRaisedListItem'
|
||||
import { LowerAllHandsButton } from '../../controls/Participants/LowerAllHandsButton'
|
||||
import { WaitingParticipantListItem } from './WaitingParticipantListItem'
|
||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||
export const ParticipantsList = () => {
|
||||
@@ -31,10 +36,13 @@ export const ParticipantsList = () => {
|
||||
]
|
||||
|
||||
const raisedHandParticipants = participants.filter((participant) => {
|
||||
const data = JSON.parse(participant.metadata || '{}')
|
||||
const data = safeParseMetadata(participant.metadata)
|
||||
return data.raised
|
||||
})
|
||||
|
||||
const { waitingParticipants, handleParticipantEntry } =
|
||||
useWaitingParticipants()
|
||||
|
||||
// TODO - extract inline styling in a centralized styling file, and avoid magic numbers
|
||||
return (
|
||||
<Div overflowY="scroll">
|
||||
@@ -50,9 +58,25 @@ export const ParticipantsList = () => {
|
||||
>
|
||||
{t('subheading').toUpperCase()}
|
||||
</H>
|
||||
{waitingParticipants?.length > 0 && (
|
||||
<Div marginBottom=".9375rem">
|
||||
<ParticipantsCollapsableList<WaitingParticipant>
|
||||
heading={t('waiting.title')}
|
||||
participants={waitingParticipants}
|
||||
renderParticipant={(participant) => (
|
||||
<WaitingParticipantListItem
|
||||
key={participant.id}
|
||||
participant={participant}
|
||||
onAction={handleParticipantEntry}
|
||||
/>
|
||||
)}
|
||||
action={() => <></>}
|
||||
/>
|
||||
</Div>
|
||||
)}
|
||||
{raisedHandParticipants.length > 0 && (
|
||||
<Div marginBottom=".9375rem">
|
||||
<ParticipantsCollapsableList
|
||||
<ParticipantsCollapsableList<Participant>
|
||||
heading={t('raisedHands')}
|
||||
participants={raisedHandParticipants}
|
||||
renderParticipant={(participant) => (
|
||||
@@ -67,7 +91,7 @@ export const ParticipantsList = () => {
|
||||
/>
|
||||
</Div>
|
||||
)}
|
||||
<ParticipantsCollapsableList
|
||||
<ParticipantsCollapsableList<Participant>
|
||||
heading={t('contributors')}
|
||||
participants={sortedParticipants}
|
||||
renderParticipant={(participant) => (
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
|
||||
export const WaitingParticipantListItem = ({
|
||||
participant,
|
||||
onAction,
|
||||
}: {
|
||||
participant: WaitingParticipant
|
||||
onAction: (participant: WaitingParticipant, allowEntry: boolean) => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
|
||||
return (
|
||||
<HStack
|
||||
role="listitem"
|
||||
justify="space-between"
|
||||
key={participant.id}
|
||||
id={participant.id}
|
||||
className={css({
|
||||
padding: '0.25rem 0',
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack
|
||||
className={css({
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
})}
|
||||
>
|
||||
<Avatar name={participant.username} bgColor={participant.color} />
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</span>
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
gap="0.25rem"
|
||||
className={css({
|
||||
flexShrink: '0',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
onPress={() => onAction(participant, true)}
|
||||
aria-label={t('waiting.accept.label', { name: participant.username })}
|
||||
data-attr="participants-accept"
|
||||
>
|
||||
{t('participants.waiting.accept.button')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
square
|
||||
tooltip={t('participants.waiting.deny.button')}
|
||||
variant="secondaryText"
|
||||
onPress={() => onAction(participant, false)}
|
||||
aria-label={t('waiting.deny.label', { name: participant.username })}
|
||||
data-attr="participants-deny"
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEmotionLine } from '@remixicon/react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Menu, ToggleButton, Button } from '@/primitives'
|
||||
import { ToggleButton, Button } from '@/primitives'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||
import {
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
||||
import { Participant } from 'livekit-client'
|
||||
|
||||
const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
|
||||
|
||||
export interface Reaction {
|
||||
id: number
|
||||
@@ -27,6 +28,8 @@ export const ReactionsToggle = () => {
|
||||
const instanceIdRef = useRef(0)
|
||||
const room = useRoomContext()
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
const sendReaction = async (emoji: string) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
@@ -53,41 +56,96 @@ export const ReactionsToggle = () => {
|
||||
}, ANIMATION_DURATION)
|
||||
}
|
||||
|
||||
// Custom animation implementation for the emoji toolbar
|
||||
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
|
||||
// animation isn't perfect
|
||||
const [isRendered, setIsRendered] = useState(isVisible)
|
||||
const [opacity, setOpacity] = useState(isVisible ? 1 : 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
// Show: first render, then animate in
|
||||
setIsRendered(true)
|
||||
// Need to delay setting opacity to ensure CSS transition works
|
||||
// (using requestAnimationFrame to ensure DOM has updated)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setOpacity(1)
|
||||
})
|
||||
})
|
||||
} else if (isRendered) {
|
||||
// Hide: first animate out, then unrender
|
||||
setOpacity(0)
|
||||
|
||||
// Wait for animation to complete before removing from DOM
|
||||
const timer = setTimeout(() => {
|
||||
setIsRendered(false)
|
||||
}, 200) // Match this to your animation duration
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isVisible, isRendered])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu variant="dark" placement="top">
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
tooltip={t('button')}
|
||||
onPress={() => setIsVisible(!isVisible)}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
</ToggleButton>
|
||||
<RACToolbar
|
||||
className={css({
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
{EMOJIS.map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => sendReaction(emoji)}
|
||||
aria-label={t('send', { emoji })}
|
||||
variant="quaternaryText"
|
||||
size="sm"
|
||||
{isRendered && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: -55,
|
||||
left: -114,
|
||||
borderRadius: '8px',
|
||||
padding: '0.25rem',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
opacity: opacity,
|
||||
transition: 'opacity 0.2s ease',
|
||||
})}
|
||||
onTransitionEnd={() => {
|
||||
if (!isVisible) {
|
||||
setIsRendered(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RACToolbar
|
||||
className={css({
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '20px',
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</RACToolbar>
|
||||
</Menu>
|
||||
{EMOJIS.map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => sendReaction(emoji)}
|
||||
aria-label={t('send', { emoji })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
data-attr={`send-reaction-${emoji}`}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '20px',
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</RACToolbar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ReactionPortals reactions={reactions} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ export const TranscriptToggle = ({
|
||||
onPress?.(e)
|
||||
}}
|
||||
{...props}
|
||||
data-attr="toggle-transcript"
|
||||
>
|
||||
<RiBardLine />
|
||||
</ToggleButton>
|
||||
|
||||
@@ -275,6 +275,7 @@ export const EffectsConfiguration = ({
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
data-attr="toggle-blur-light"
|
||||
>
|
||||
<BlurOn />
|
||||
</ToggleButton>
|
||||
@@ -295,6 +296,7 @@ export const EffectsConfiguration = ({
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
data-attr="toggle-blur-normal"
|
||||
>
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
@@ -349,7 +351,8 @@ export const EffectsConfiguration = ({
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
></ToggleButton>
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ export enum PanelId {
|
||||
EFFECTS = 'effects',
|
||||
CHAT = 'chat',
|
||||
TRANSCRIPT = 'transcript',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
|
||||
export const useSidePanel = () => {
|
||||
@@ -16,8 +17,13 @@ export const useSidePanel = () => {
|
||||
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
||||
const isChatOpen = activePanelId == PanelId.CHAT
|
||||
const isTranscriptOpen = activePanelId == PanelId.TRANSCRIPT
|
||||
const isAdminOpen = activePanelId == PanelId.ADMIN
|
||||
const isSidePanelOpen = !!activePanelId
|
||||
|
||||
const toggleAdmin = () => {
|
||||
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
||||
}
|
||||
|
||||
const toggleParticipants = () => {
|
||||
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||
}
|
||||
@@ -40,10 +46,12 @@ export const useSidePanel = () => {
|
||||
toggleChat,
|
||||
toggleEffects,
|
||||
toggleTranscript,
|
||||
toggleAdmin,
|
||||
isChatOpen,
|
||||
isParticipantsOpen,
|
||||
isEffectsOpen,
|
||||
isSidePanelOpen,
|
||||
isTranscriptOpen,
|
||||
isAdminOpen,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function DesktopControlBar({
|
||||
gap: '0.5rem',
|
||||
marginLeft: '0.5rem',
|
||||
})}
|
||||
></div>
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
flex: '1 1 33%',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { SupportToggle } from '../../components/controls/SupportToggle'
|
||||
import { TranscriptToggle } from '../../components/controls/TranscriptToggle'
|
||||
import { AdminToggle } from '../../components/AdminToggle'
|
||||
import { useSize } from '../../hooks/useResizeObserver'
|
||||
import { useState, RefObject } from 'react'
|
||||
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
|
||||
@@ -11,7 +12,7 @@ import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const CONTROL_BAR_BREAKPOINT = 750
|
||||
const CONTROL_BAR_BREAKPOINT = 1100
|
||||
|
||||
const NavigationControls = ({ onPress }: Partial<ToggleButtonProps>) => (
|
||||
<>
|
||||
@@ -19,6 +20,7 @@ const NavigationControls = ({ onPress }: Partial<ToggleButtonProps>) => (
|
||||
<ParticipantsToggle onPress={onPress} />
|
||||
<TranscriptToggle onPress={onPress} />
|
||||
<SupportToggle onPress={onPress} />
|
||||
<AdminToggle onPress={onPress} />
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export const Room = () => {
|
||||
if (!userConfig && !skipJoinScreen) {
|
||||
return (
|
||||
<UserAware>
|
||||
<Join onSubmit={setUserConfig} />
|
||||
<Join onSubmit={setUserConfig} roomId={roomId} />
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
import { Participant } from 'livekit-client'
|
||||
|
||||
export const getParticipantColor = (
|
||||
participant: Participant
|
||||
): undefined | string => {
|
||||
const { metadata } = participant
|
||||
if (!metadata) {
|
||||
return
|
||||
}
|
||||
return JSON.parse(metadata)['color']
|
||||
const DEFAULT_COLOR = 'rgb(87, 44, 216)'
|
||||
|
||||
const HSL_REGEX =
|
||||
/^hsl\(([0-9]|[1-9][0-9]|[1-2][0-9][0-9]|3[0-5][0-9]|360),\s*([5-7][0-9]|50|75)%,\s*([2-5][0-9]|60)%\)$/
|
||||
|
||||
function isValidHsl(colorString: string) {
|
||||
return HSL_REGEX.test(colorString)
|
||||
}
|
||||
|
||||
export const getParticipantColor = (participant: Participant): string => {
|
||||
const { metadata } = participant
|
||||
|
||||
if (!metadata) {
|
||||
return DEFAULT_COLOR
|
||||
}
|
||||
|
||||
let parsedMetadata: unknown
|
||||
|
||||
try {
|
||||
parsedMetadata = JSON.parse(metadata)
|
||||
} catch (error) {
|
||||
console.error('Invalid JSON in participant metadata:', error)
|
||||
return DEFAULT_COLOR
|
||||
}
|
||||
|
||||
if (!parsedMetadata || typeof parsedMetadata !== 'object') {
|
||||
return DEFAULT_COLOR
|
||||
}
|
||||
|
||||
const colorValue = (parsedMetadata as Record<string, unknown>)['color']
|
||||
|
||||
if (typeof colorValue !== 'string' || !isValidHsl(colorValue)) {
|
||||
console.error('Invalid color value:', colorValue)
|
||||
return DEFAULT_COLOR
|
||||
}
|
||||
|
||||
return colorValue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const safeParseMetadata = (
|
||||
metadataStr: string | null | undefined
|
||||
): Record<string, unknown> => {
|
||||
if (!metadataStr) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(metadataStr)
|
||||
|
||||
// Ensure the result is an object
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
console.warn('Metadata parsed to non-object value:', parsed)
|
||||
return {}
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
} catch (error) {
|
||||
console.error('Failed to parse metadata:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
||||
import { logoutUrl, useUser } from '@/features/auth'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
|
||||
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
||||
|
||||
export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
const { t, i18n } = useTranslation('settings')
|
||||
const { user, isLoggedIn } = useUser()
|
||||
const { user, isLoggedIn, logout } = useUser()
|
||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||
return (
|
||||
<Dialog title={t('dialog.heading')} {...props}>
|
||||
@@ -23,7 +23,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
/>
|
||||
</P>
|
||||
<P>
|
||||
<A href={logoutUrl()}>{t('logout', { ns: 'global' })}</A>
|
||||
<A onPress={logout}>{t('logout', { ns: 'global' })}</A>
|
||||
</P>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { A, Badge, Button, DialogProps, Field, H, P } from '@/primitives'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { logoutUrl, useUser } from '@/features/auth'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
@@ -16,7 +16,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const { saveUsername } = usePersistentUserChoices()
|
||||
const room = useRoomContext()
|
||||
const { user, isLoggedIn } = useUser()
|
||||
const { user, isLoggedIn, logout } = useUser()
|
||||
const [name, setName] = useState(room?.localParticipant.name || '')
|
||||
|
||||
const handleOnSubmit = () => {
|
||||
@@ -51,7 +51,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
/>
|
||||
</P>
|
||||
<P>
|
||||
<A href={logoutUrl()}>{t('logout', { ns: 'global' })}</A>
|
||||
<A onPress={logout}>{t('logout', { ns: 'global' })}</A>
|
||||
</P>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { hintsStore } from '@/stores/hints'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const useLoginHint = () => {
|
||||
const hintsSnap = useSnapshot(hintsStore)
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
const openLoginHint = () => (hintsStore.showLoginHint = true)
|
||||
const closeLoginHint = () => (hintsStore.showLoginHint = false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && hintsSnap.showLoginHint) closeLoginHint()
|
||||
}, [isLoggedIn, hintsSnap])
|
||||
|
||||
return {
|
||||
isVisible: hintsSnap.showLoginHint,
|
||||
openLoginHint,
|
||||
closeLoginHint,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export const usePrevious = <T>(value: T): T | undefined => {
|
||||
const ref = useRef<T>()
|
||||
useEffect(() => {
|
||||
ref.current = value
|
||||
}, [value])
|
||||
return ref.current
|
||||
}
|
||||
@@ -14,7 +14,7 @@ i18n
|
||||
.use(initReactI18next)
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
supportedLngs: ['en', 'fr'],
|
||||
supportedLngs: ['en', 'fr', 'nl'],
|
||||
fallbackLng: 'fr',
|
||||
ns: i18nDefaultNamespace,
|
||||
detection: {
|
||||
|
||||
@@ -4,6 +4,7 @@ const languageLabels: Record<string, string> = {
|
||||
en: 'English',
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
nl: 'Nederlands',
|
||||
}
|
||||
|
||||
export const useLanguageLabels = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { A } from '@/primitives'
|
||||
import { A, Link } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const StyledLi = styled('li', {
|
||||
@@ -221,15 +221,24 @@ export const Footer = () => {
|
||||
</FirstRow>
|
||||
<SecondRow>
|
||||
<StyledLi divider>
|
||||
<A
|
||||
externalIcon
|
||||
<Link
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href="https://docs.numerique.gouv.fr/docs/f88a2eb0-7ce7-4016-b6ee-9f1fd1771951/"
|
||||
aria-label={t('links.legalsTerms') + ' - ' + t('links.ariaLabel')}
|
||||
to="/mentions-legales"
|
||||
aria-label={t('links.legalsTerms')}
|
||||
>
|
||||
{t('links.legalsTerms')}
|
||||
</A>
|
||||
</Link>
|
||||
</StyledLi>
|
||||
<StyledLi divider>
|
||||
<Link
|
||||
underline={false}
|
||||
footer="minor"
|
||||
to="/conditions-utilisation"
|
||||
aria-label={t('links.termsOfService')}
|
||||
>
|
||||
{t('links.termsOfService')}
|
||||
</Link>
|
||||
</StyledLi>
|
||||
<StyledLi divider>
|
||||
<A
|
||||
@@ -243,27 +252,26 @@ export const Footer = () => {
|
||||
</A>
|
||||
</StyledLi>
|
||||
<StyledLi divider>
|
||||
<A
|
||||
externalIcon
|
||||
<Link
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href="https://docs.numerique.gouv.fr/docs/94bd1e3b-a44d-4cf5-b7ee-708a5386a111/"
|
||||
aria-label={
|
||||
t('links.accessibility') + ' - ' + t('links.ariaLabel')
|
||||
}
|
||||
to="/accessibilite"
|
||||
aria-label={t('links.accessibility')}
|
||||
>
|
||||
{t('links.accessibility')}
|
||||
</A>
|
||||
</Link>
|
||||
</StyledLi>
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href="https://github.com/numerique-gouv/meet/"
|
||||
aria-label={t('links.code') + ' - ' + t('links.ariaLabel')}
|
||||
href="https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/"
|
||||
aria-label={
|
||||
t('links.technicalDetails') + ' - ' + t('links.ariaLabel')
|
||||
}
|
||||
>
|
||||
{t('links.code')}
|
||||
{t('links.technicalDetails')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
</SecondRow>
|
||||
@@ -276,6 +284,17 @@ export const Footer = () => {
|
||||
>
|
||||
{t('license')}
|
||||
</A>
|
||||
.
|
||||
<br /> {t('links.codeAnnotation')}{' '}
|
||||
<A
|
||||
externalIcon
|
||||
footer="minor"
|
||||
href="https://github.com/numerique-gouv/meet/"
|
||||
aria-label={t('links.code') + ' - ' + t('links.ariaLabel')}
|
||||
>
|
||||
{t('links.code')}
|
||||
</A>
|
||||
.
|
||||
</ThirdRow>
|
||||
</InnerContainer>
|
||||
</footer>
|
||||
|
||||
@@ -2,18 +2,17 @@ import { Link } from 'wouter'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack, Stack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { SettingsButton } from '@/features/settings'
|
||||
import { logoutUrl, useUser } from '@/features/auth'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
||||
import { FeedbackBanner } from '@/components/FeedbackBanner'
|
||||
import { Menu } from '@/primitives/Menu'
|
||||
import { MenuList } from '@/primitives/MenuList'
|
||||
import { ProConnectButton } from '@/components/ProConnectButton'
|
||||
import { terminateAnalyticsSession } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { terminateSupportSession } from '@/features/support/hooks/useSupport'
|
||||
|
||||
import LogoAsset from '@/assets/logo.svg'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
|
||||
const Marianne = () => {
|
||||
return (
|
||||
@@ -93,11 +92,72 @@ const Logo = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const LoginHint = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isVisible, closeLoginHint } = useLoginHint()
|
||||
if (!isVisible) return null
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '103px',
|
||||
right: '110px',
|
||||
zIndex: '100',
|
||||
outline: 'none',
|
||||
padding: '1.25rem',
|
||||
maxWidth: '350px',
|
||||
boxShadow: '0 2px 5px rgba(0 0 0 / 0.1)',
|
||||
borderRadius: '1rem',
|
||||
backgroundColor: 'primary.200',
|
||||
display: 'none',
|
||||
xsm: {
|
||||
display: 'block',
|
||||
},
|
||||
sm: {
|
||||
top: '131px',
|
||||
right: '100px',
|
||||
zIndex: '100',
|
||||
},
|
||||
_after: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '-10px',
|
||||
right: '20%',
|
||||
marginLeft: '-10px',
|
||||
borderWidth: '0 10px 10px 10px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'transparent transparent #E3E3FB transparent',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Text variant="h3" margin={false} bold>
|
||||
{t('loginHint.title')}
|
||||
</Text>
|
||||
<Text variant="paragraph" margin={false}>
|
||||
{t('loginHint.body')}
|
||||
</Text>
|
||||
<Button
|
||||
aria-label={t('loginHint.button.ariaLabel')}
|
||||
size="sm"
|
||||
className={css({
|
||||
marginLeft: 'auto',
|
||||
})}
|
||||
onPress={() => closeLoginHint()}
|
||||
>
|
||||
{t('loginHint.button.label')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
const isHome = useMatchesRoute('home')
|
||||
const isLegalTerms = useMatchesRoute('legalTerms')
|
||||
const isAccessibility = useMatchesRoute('accessibility')
|
||||
const isTermsOfService = useMatchesRoute('termsOfService')
|
||||
const isRoom = useMatchesRoute('room')
|
||||
const { user, isLoggedIn } = useUser()
|
||||
const { user, isLoggedIn, logout } = useUser()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -145,9 +205,16 @@ export const Header = () => {
|
||||
</header>
|
||||
<nav>
|
||||
<Stack gap={1} direction="row" align="center">
|
||||
{isLoggedIn === false && !isHome && (
|
||||
<ProConnectButton hint={false} />
|
||||
)}
|
||||
{isLoggedIn === false &&
|
||||
!isHome &&
|
||||
!isLegalTerms &&
|
||||
!isAccessibility &&
|
||||
!isTermsOfService && (
|
||||
<>
|
||||
<ProConnectButton hint={false} />
|
||||
<LoginHint />
|
||||
</>
|
||||
)}
|
||||
{!!user && (
|
||||
<Menu>
|
||||
<Button
|
||||
@@ -173,9 +240,7 @@ export const Header = () => {
|
||||
items={[{ value: 'logout', label: t('logout') }]}
|
||||
onAction={(value) => {
|
||||
if (value === 'logout') {
|
||||
terminateAnalyticsSession()
|
||||
terminateSupportSession()
|
||||
window.location.href = logoutUrl()
|
||||
logout()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { layoutStore } from '@/stores/layout'
|
||||
import { Layout } from './Layout'
|
||||
import { useEffect } from 'react'
|
||||
import { Centered } from './Centered'
|
||||
import { H } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
export type ScreenProps = {
|
||||
/**
|
||||
@@ -14,6 +16,7 @@ export type ScreenProps = {
|
||||
*/
|
||||
header?: boolean
|
||||
footer?: boolean
|
||||
headerTitle?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
@@ -21,6 +24,7 @@ export const Screen = ({
|
||||
layout = 'fullpage',
|
||||
header = true,
|
||||
footer = true,
|
||||
headerTitle,
|
||||
children,
|
||||
}: ScreenProps) => {
|
||||
useEffect(() => {
|
||||
@@ -31,5 +35,40 @@ export const Screen = ({
|
||||
layoutStore.showFooter = footer
|
||||
}
|
||||
}, [header, footer])
|
||||
return layout === 'centered' ? <Centered>{children}</Centered> : children
|
||||
|
||||
return (
|
||||
<>
|
||||
{headerTitle && (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primary.100',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '100%',
|
||||
width: '38rem',
|
||||
margin: 'auto',
|
||||
paddingX: '2rem',
|
||||
})}
|
||||
>
|
||||
<H
|
||||
lvl={1}
|
||||
margin={false}
|
||||
className={css({
|
||||
paddingY: '2rem',
|
||||
md: {
|
||||
paddingY: '3rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{headerTitle}
|
||||
</H>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{layout === 'centered' ? <Centered>{children}</Centered> : children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"accessibility": {
|
||||
"title": "",
|
||||
"introduction": "",
|
||||
"declaration": {
|
||||
"title": "",
|
||||
"date": ""
|
||||
},
|
||||
"scope": "",
|
||||
"complianceStatus": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
"improvement": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"contact": {
|
||||
"email": "",
|
||||
"address": ""
|
||||
},
|
||||
"response": ""
|
||||
},
|
||||
"recourse": {
|
||||
"title": "",
|
||||
"introduction": "",
|
||||
"options": {
|
||||
"intro": "",
|
||||
"option1": "",
|
||||
"option2": "",
|
||||
"option3": ""
|
||||
}
|
||||
},
|
||||
"dataProtection": {
|
||||
"line1": "",
|
||||
"line2": "",
|
||||
"line3": "",
|
||||
"line4": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@
|
||||
},
|
||||
"logout": "",
|
||||
"notFound": {
|
||||
"heading": ""
|
||||
"heading": "",
|
||||
"body": ""
|
||||
},
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
@@ -35,9 +36,20 @@
|
||||
"data": "",
|
||||
"accessibility": "",
|
||||
"ariaLabel": "",
|
||||
"code": ""
|
||||
"codeAnnotation": "",
|
||||
"code": "",
|
||||
"technicalDetails": "",
|
||||
"termsOfService": ""
|
||||
},
|
||||
"mentions": "",
|
||||
"license": ""
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"button": {
|
||||
"ariaLabel": "",
|
||||
"label": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"title": "",
|
||||
"creator": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"contact": {
|
||||
"title": "",
|
||||
"address": "",
|
||||
"phone": "",
|
||||
"siret": "",
|
||||
"siren": ""
|
||||
}
|
||||
},
|
||||
"director": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
"hosting": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"address": {
|
||||
"line1": "",
|
||||
"line2": "",
|
||||
"line3": "",
|
||||
"line4": ""
|
||||
}
|
||||
},
|
||||
"accessibility": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"status": "",
|
||||
"reporting": {
|
||||
"title": "",
|
||||
"body1": "",
|
||||
"body2": "",
|
||||
"address": {
|
||||
"line1": "",
|
||||
"line2": "",
|
||||
"line3": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"reuse": {
|
||||
"title": "",
|
||||
"body1": "",
|
||||
"body2": "",
|
||||
"media": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
}
|
||||
},
|
||||
"liability": {
|
||||
"title": "",
|
||||
"body1": "",
|
||||
"body2": "",
|
||||
"body3": "",
|
||||
"body4": ""
|
||||
},
|
||||
"dataProtection": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"address": {
|
||||
"line1": "",
|
||||
"line2": "",
|
||||
"line3": "",
|
||||
"line4": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,11 @@
|
||||
},
|
||||
"reaction": {
|
||||
"description": ""
|
||||
},
|
||||
"waitingParticipants": {
|
||||
"one": "",
|
||||
"several": "",
|
||||
"open": "",
|
||||
"accept": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,19 @@
|
||||
"usernameEmpty": ""
|
||||
},
|
||||
"cameraDisabled": "",
|
||||
"cameraStarting": ""
|
||||
"cameraStarting": "",
|
||||
"waiting": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
"denied": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
},
|
||||
"timeoutInvite": {
|
||||
"title": "",
|
||||
"body": ""
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "",
|
||||
"shareDialog": {
|
||||
@@ -85,6 +97,10 @@
|
||||
"open": "",
|
||||
"closed": ""
|
||||
},
|
||||
"admin": {
|
||||
"open": "",
|
||||
"closed": ""
|
||||
},
|
||||
"support": "",
|
||||
"moreOptions": "",
|
||||
"reactions": {
|
||||
@@ -130,13 +146,15 @@
|
||||
"participants": "",
|
||||
"effects": "",
|
||||
"chat": "",
|
||||
"transcript": ""
|
||||
"transcript": "",
|
||||
"admin": ""
|
||||
},
|
||||
"content": {
|
||||
"participants": "",
|
||||
"effects": "",
|
||||
"chat": "",
|
||||
"transcript": ""
|
||||
"transcript": "",
|
||||
"admin": ""
|
||||
},
|
||||
"closeButton": ""
|
||||
},
|
||||
@@ -155,6 +173,27 @@
|
||||
"button": ""
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"description": "",
|
||||
"access": {
|
||||
"title": "",
|
||||
"description": "",
|
||||
"levels": {
|
||||
"public": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"trusted": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
},
|
||||
"restricted": {
|
||||
"label": "",
|
||||
"description": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rating": {
|
||||
"submit": "",
|
||||
"question": "",
|
||||
@@ -173,6 +212,12 @@
|
||||
"heading": "",
|
||||
"body": ""
|
||||
},
|
||||
"authenticationMessage": {
|
||||
"heading": "",
|
||||
"placeholder": "",
|
||||
"submit": "",
|
||||
"ignore": ""
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "",
|
||||
"contributors": "",
|
||||
@@ -191,7 +236,20 @@
|
||||
},
|
||||
"raisedHands": "",
|
||||
"lowerParticipantHand": "",
|
||||
"lowerParticipantsHand": ""
|
||||
"lowerParticipantsHand": "",
|
||||
"waiting": {
|
||||
"title": "",
|
||||
"accept": {
|
||||
"button": "",
|
||||
"label": "",
|
||||
"all": ""
|
||||
},
|
||||
"deny": {
|
||||
"button": "",
|
||||
"label": "",
|
||||
"all": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"label": ""
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"terms": {
|
||||
"title": "",
|
||||
"introduction": "",
|
||||
"articles": {
|
||||
"article1": {
|
||||
"title": "",
|
||||
"content": ""
|
||||
},
|
||||
"article2": {
|
||||
"title": "",
|
||||
"content": "",
|
||||
"purposes": []
|
||||
},
|
||||
"article3": {
|
||||
"title": "",
|
||||
"definitions": []
|
||||
},
|
||||
"article4": {
|
||||
"title": "",
|
||||
"content": ""
|
||||
},
|
||||
"article5": {
|
||||
"title": "",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "",
|
||||
"content": "",
|
||||
"paragraph1": "",
|
||||
"paragraph2": "",
|
||||
"capabilities": [],
|
||||
"paragraph3": ""
|
||||
},
|
||||
"section2": {
|
||||
"title": "",
|
||||
"content": "",
|
||||
"paragraph": "",
|
||||
"capabilities": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"article6": {
|
||||
"title": "",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "",
|
||||
"paragraphs": []
|
||||
},
|
||||
"section2": {
|
||||
"title": "",
|
||||
"paragraphs": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"article7": {
|
||||
"title": "",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "",
|
||||
"content": ""
|
||||
},
|
||||
"section2": {
|
||||
"title": "",
|
||||
"content": "",
|
||||
"dataCategories": []
|
||||
},
|
||||
"section3": {
|
||||
"title": "",
|
||||
"content": "",
|
||||
"purposes": []
|
||||
},
|
||||
"section4": {
|
||||
"title": "",
|
||||
"content": "",
|
||||
"bases": [],
|
||||
"details": []
|
||||
},
|
||||
"section5": {
|
||||
"title": "",
|
||||
"content": "",
|
||||
"dataType": "",
|
||||
"retentionPeriod": "",
|
||||
"retentionTable": [],
|
||||
"additionalInfo": ""
|
||||
},
|
||||
"section6": {
|
||||
"title": "",
|
||||
"content": ""
|
||||
},
|
||||
"section7": {
|
||||
"title": "",
|
||||
"content": "",
|
||||
"rights": [],
|
||||
"exerciseRights": {
|
||||
"content": "",
|
||||
"methods": []
|
||||
},
|
||||
"additionalInfo": []
|
||||
},
|
||||
"section8": {
|
||||
"title": "",
|
||||
"paragraphs": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"accessibility": {
|
||||
"title": "Accessibility",
|
||||
"introduction": "Visio is committed to making its digital services accessible, in accordance with Article 47 of Law No. 2005-102 of February 11, 2005.",
|
||||
"declaration": {
|
||||
"title": "Accessibility Declaration",
|
||||
"date": "Established on March 5, 2025."
|
||||
},
|
||||
"scope": "This accessibility declaration applies to the site visio.numerique.gouv.fr",
|
||||
"complianceStatus": {
|
||||
"title": "Compliance Status",
|
||||
"body": "visio.numerique.gouv.fr is not compliant with RGAA 4.1. The site has not yet been audited. However, the team is striving to create a site accessible to all by following RGAA recommendations."
|
||||
},
|
||||
"improvement": {
|
||||
"title": "Improvement and Contact",
|
||||
"body": "If you are unable to access content or a service, you can contact the administrator of lasuite.numerique.gouv.fr to be directed to an accessible alternative or obtain the content in another format.",
|
||||
"contact": {
|
||||
"email": "Email: visio@numerique.gouv.fr",
|
||||
"address": "Address: DINUM, 20 avenue de Ségur 75007 Paris"
|
||||
},
|
||||
"response": "We try to respond within 2 business days."
|
||||
},
|
||||
"recourse": {
|
||||
"title": "Recourse",
|
||||
"introduction": "This procedure should be used in the following case: you have reported an accessibility issue to the website administrator that prevents you from accessing content or services on the portal, and you have not received a satisfactory response.",
|
||||
"options": {
|
||||
"intro": "You can:",
|
||||
"option1": "Send a message to the Defender of Rights",
|
||||
"option2": "Contact the delegate of the Defender of Rights in your region",
|
||||
"option3": "Send a letter by mail (free, do not put a stamp):"
|
||||
}
|
||||
},
|
||||
"dataProtection": {
|
||||
"line1": "Service du Premier ministre",
|
||||
"line2": "A l'attention du délégué à la protection des données (DPD)",
|
||||
"line3": "56 rue de Varenne",
|
||||
"line4": "75007 Paris"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@
|
||||
},
|
||||
"logout": "Logout",
|
||||
"notFound": {
|
||||
"heading": "Page not found"
|
||||
"heading": "Verify your meeting code",
|
||||
"body": "Check that you have entered the correct meeting code in the URL. Example:"
|
||||
},
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
@@ -33,11 +34,22 @@
|
||||
"datagouv": "data.gouv.fr",
|
||||
"legalsTerms": "Legal Notice",
|
||||
"data": "Personal Data and Cookies",
|
||||
"accessibility": "Accessibility: audit in progress",
|
||||
"accessibility": "Accessibility: non-compliant",
|
||||
"ariaLabel": "new window",
|
||||
"code": "Open Source Code Repository"
|
||||
"codeAnnotation": "Our code is open and available on this",
|
||||
"code": "Open Source Code Repository",
|
||||
"technicalDetails": "Technical Notice",
|
||||
"termsOfService": "Terms of service"
|
||||
},
|
||||
"mentions": "Unless otherwise stated, the contents of this site are available under",
|
||||
"license": "etalab 2.0 license"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Log in with your ProConnect account",
|
||||
"body": "Instead of waiting, log in with your ProConnect account.",
|
||||
"button": {
|
||||
"ariaLabel": "Close the suggestion",
|
||||
"label": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"title": "Legal Notices",
|
||||
"creator": {
|
||||
"title": "Publisher",
|
||||
"body": "The Visio site is published by the interministerial digital directorate (DINUM), hereinafter referred to as \"the publisher\", belonging to the Prime Minister's services.",
|
||||
"contact": {
|
||||
"title": "Contact Information",
|
||||
"address": "Address: {{address}}",
|
||||
"phone": "Reception Tel.: {{phone}}",
|
||||
"siret": "SIRET: {{siret}}",
|
||||
"siren": "SIREN: {{siren}}"
|
||||
}
|
||||
},
|
||||
"director": {
|
||||
"title": "Publication Director",
|
||||
"body": "Interministerial Digital Director."
|
||||
},
|
||||
"hosting": {
|
||||
"title": "Hosting",
|
||||
"body": "This website is hosted by Outscale with SecNumCloud certification:",
|
||||
"address": {
|
||||
"line1": "Outscale",
|
||||
"line2": "1, rue Royale",
|
||||
"line3": "319 Bureaux de la Colline",
|
||||
"line4": "92210 Saint-Cloud"
|
||||
}
|
||||
},
|
||||
"accessibility": {
|
||||
"title": "Accessibility",
|
||||
"body": "Compliance with digital accessibility standards is a future objective. The site has not yet been audited. However, the team strives to create a site accessible to all by following the RGAA recommendations.",
|
||||
"status": "Accessibility status: non-compliant",
|
||||
"reporting": {
|
||||
"title": "Report a malfunction",
|
||||
"body1": "If you encounter an accessibility issue preventing you from accessing content or a site feature, please let us know by contacting visio@numerique.gouv.fr.",
|
||||
"body2": "If you do not receive a prompt response from us, you have the right to send your grievances or a request for referral to the Rights Defender. You have the right to send your grievances or request for referral to the \"Rights Defender\" by phone at 09 69 39 00 00, via the online form https://formulaire.defenseurdesdroits.fr or by free mail (no postage required):",
|
||||
"address": {
|
||||
"line1": "Défenseur des droits",
|
||||
"line2": "Libre réponse 71120",
|
||||
"line3": "75342 Paris CEDEX 07"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reuse": {
|
||||
"title": "Content Reuse and Links",
|
||||
"body1": "Unless explicitly mentioned as intellectual property held by third parties, the contents of this site are offered under an open license.",
|
||||
"body2": "Any public or private site is authorized to establish, without prior authorization, a link (including deep linking) to the information disseminated on this site.",
|
||||
"media": {
|
||||
"title": "Graphics, Photos and Videos",
|
||||
"body": "Graphics, photographs, and multimedia resources are most often covered by intellectual property rights held by third parties and are not, as such, reusable without their consent. Therefore, we invite you to contact us if you wish to reuse such content so that we can direct you to the holder(s) of these rights: visio@numerique.gouv.fr."
|
||||
}
|
||||
},
|
||||
"liability": {
|
||||
"title": "Liability Clause",
|
||||
"body1": "The information offered on this site is provided as a service to public service agents, particularly to agents of State services.",
|
||||
"body2": "The information and/or documents available on this site are subject to change at any time and may be updated to facilitate access to digital resources.",
|
||||
"body3": "The information and/or documents accessed from this site are the responsibility of the owners of each interministerial service thus made available.",
|
||||
"body4": "The publisher cannot under any circumstances be held responsible for any damage of any nature whatsoever resulting from the misuse of resources accessible from this site."
|
||||
},
|
||||
"dataProtection": {
|
||||
"title": "Data Protection Officer",
|
||||
"body": "If you wish to obtain information about personal data processing managed by a Prime Minister's Service, you can contact the data protection officer (DPO) by email at dpd@pm.gouv.fr or by mail at the following address:",
|
||||
"address": {
|
||||
"line1": "Service du Premier ministre",
|
||||
"line2": "A l'attention du délégué à la protection des données (DPD)",
|
||||
"line3": "56 rue de Varenne",
|
||||
"line4": "75700 Paris"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,11 @@
|
||||
},
|
||||
"reaction": {
|
||||
"description": "{{name}} reacted with {{emoji}}"
|
||||
},
|
||||
"waitingParticipants": {
|
||||
"one": "One person wants to join this call.",
|
||||
"several": "Several people want to join this call.",
|
||||
"open": "Open",
|
||||
"accept": "Accept"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"title": "Effects",
|
||||
"subTitle": "Configure your camera's effects."
|
||||
},
|
||||
"heading": "Join the meeting",
|
||||
"heading": "Join the meeting?",
|
||||
"joinLabel": "Join",
|
||||
"joinMeeting": "Join meeting",
|
||||
"toggleOff": "Click to turn off",
|
||||
@@ -34,7 +34,19 @@
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled.",
|
||||
"cameraStarting": "Camera is starting."
|
||||
"cameraStarting": "Camera is starting.",
|
||||
"waiting": {
|
||||
"title": "Requesting to join...",
|
||||
"body": "You will be able to join this call when someone authorizes you"
|
||||
},
|
||||
"denied": {
|
||||
"title": "You cannot join this call",
|
||||
"body": "Your request to join has been denied."
|
||||
},
|
||||
"timeoutInvite": {
|
||||
"title": "You cannot join this call",
|
||||
"body": "No one responded to your request"
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
@@ -84,6 +96,10 @@
|
||||
"open": "Hide AI assistant",
|
||||
"closed": "Show AI assistant"
|
||||
},
|
||||
"admin": {
|
||||
"open": "Hide admin",
|
||||
"closed": "Open admin"
|
||||
},
|
||||
"support": "Support",
|
||||
"moreOptions": "More options",
|
||||
"reactions": {
|
||||
@@ -129,13 +145,15 @@
|
||||
"participants": "Participants",
|
||||
"effects": "Effects",
|
||||
"chat": "Messages in the chat",
|
||||
"transcript": "AI Assistant"
|
||||
"transcript": "AI Assistant",
|
||||
"admin": "Admin settings"
|
||||
},
|
||||
"content": {
|
||||
"participants": "participants",
|
||||
"effects": "effects",
|
||||
"chat": "messages",
|
||||
"transcript": "AI assistant"
|
||||
"transcript": "AI assistant",
|
||||
"admin": "Admin settings"
|
||||
},
|
||||
"closeButton": "Hide {{content}}"
|
||||
},
|
||||
@@ -154,6 +172,27 @@
|
||||
"button": "Stop Recording"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"description": "These organizer settings allow you to maintain control of your meeting. Only organizers can access these controls.",
|
||||
"access": {
|
||||
"title": "Room access",
|
||||
"description": "These settings will also apply to future occurrences of this meeting.",
|
||||
"levels": {
|
||||
"public": {
|
||||
"label": "Open",
|
||||
"description": "No one needs to request to join the meeting."
|
||||
},
|
||||
"trusted": {
|
||||
"label": "Open to trusted people",
|
||||
"description": "Authenticated people do not need to request to join the meeting."
|
||||
},
|
||||
"restricted": {
|
||||
"label": "Restricted",
|
||||
"description": "People who have not been invited to the meeting must request to join."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"rating": {
|
||||
"submit": "Submit",
|
||||
"question": "What do you think about the quality of your call?",
|
||||
@@ -172,6 +211,12 @@
|
||||
"heading": "Thank you for your submission",
|
||||
"body": "Our product team takes the time to carefully review your feedback. We will get back to you as soon as possible."
|
||||
},
|
||||
"authenticationMessage": {
|
||||
"heading": "How can we contact you?",
|
||||
"placeholder": "Your email",
|
||||
"submit": "Send",
|
||||
"ignore": "Ignore"
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "In room",
|
||||
"you": "You",
|
||||
@@ -190,7 +235,20 @@
|
||||
},
|
||||
"raisedHands": "Raised hands",
|
||||
"lowerParticipantHand": "Lower {{name}}'s hand",
|
||||
"lowerParticipantsHand": "Lower all hands"
|
||||
"lowerParticipantsHand": "Lower all hands",
|
||||
"waiting": {
|
||||
"title": "Lobby",
|
||||
"accept": {
|
||||
"button": "Accept",
|
||||
"label": "Accept {{name}} into the meeting",
|
||||
"all": "Accept all"
|
||||
},
|
||||
"deny": {
|
||||
"button": "Deny",
|
||||
"label": "Deny {{name}} from the meeting",
|
||||
"all": "Deny all"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"label": "Recording"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user