Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bdd12e464 | |||
| b1a3770e61 | |||
| f501496a0b | |||
| 1dde506e05 | |||
| 96310bff75 |
@@ -135,9 +135,6 @@ jobs:
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -155,33 +152,6 @@ jobs:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker pull minio/minio
|
||||
docker run -d --name minio \
|
||||
-p 9000:9000 \
|
||||
-e "MINIO_ACCESS_KEY=meet" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
minio/minio server --console-address :9001 /data
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for MinIO to be ready
|
||||
run: |
|
||||
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||
|
||||
- name: Configure MinIO
|
||||
run: |
|
||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||
docker exec ${MINIO} sh -c \
|
||||
"mc alias set meet http://localhost:9000 meet password && \
|
||||
mc alias ls && \
|
||||
mc mb meet/meet-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 DINUM/Etalab
|
||||
Copyright (c) 2021-2023 DINUM/Etalab
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 DINUM/Etalab
|
||||
Copyright (c) 2021-2023 DINUM/Etalab
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
|
||||
<img alt="posthoglogo" src="./docs/assets/visio-logo.png" maxWidth="100%">
|
||||
</p>
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://visio.numerique.gouv.fr/">
|
||||
<img src="https://github.com/user-attachments/assets/09c1faa1-de88-4848-af3a-6fbe793999bf" alt="La Suite Meet Demonstration">
|
||||
<img src="https://github.com/user-attachments/assets/09c1faa1-de88-4848-af3a-6fbe793999bf" alt="Visio Demonstration">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## La Suite Meet: Simple Video Conferencing
|
||||
## Visio: Simple Video Conferencing
|
||||
|
||||
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||
Powered by [LiveKit](https://livekit.io/), Visio offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||
### Features
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
@@ -45,7 +45,7 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
|
||||
- SVC codecs (VP9, AV1)
|
||||
|
||||
|
||||
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||
Visio is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||
|
||||
@@ -61,31 +61,31 @@ We’re continuously adding new features to enhance your experience, with the la
|
||||
|
||||
## Get started
|
||||
|
||||
### La Suite Meet Cloud (Recommended)
|
||||
Sign up for La Suite Meet Cloud, designed for french public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
|
||||
### Visio Cloud (Recommended)
|
||||
Sign up for Visio Cloud, designed for European public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
|
||||
|
||||
### Open-source deployment (Advanced)
|
||||
|
||||
Deploy La Suite Meet on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
|
||||
Deploy Visio on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
|
||||
|
||||
## Docs
|
||||
|
||||
We're currently working on both technical and user documentation for La Suite Meet. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
|
||||
We're currently working on both technical and user documentation for Visio. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
|
||||
|
||||
## Contributing
|
||||
|
||||
We <3 contributions of any kind, big and small:
|
||||
|
||||
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/3/views/2)
|
||||
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
||||
- Open a PR (see our instructions on [developing Visio locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
||||
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
||||
|
||||
|
||||
## Philosophy
|
||||
|
||||
We’re relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
|
||||
We’re relentlessly focused on building the best open-source video conferencing product—Visio. Growth comes from creating something people truly need, not just from chasing metrics.
|
||||
|
||||
Our users come first. We’re committed to making La Suite Meet as accessible and easy to use as proprietary solutions, ensuring it meets the highest standards.
|
||||
Our users come first. We’re committed to making Visio as accessible and easy to use as proprietary solutions, ensuring it meets the highest standards.
|
||||
|
||||
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
|
||||
|
||||
@@ -99,7 +99,7 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
### Help us!
|
||||
|
||||
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
|
||||
Come help us make Visio even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
|
||||
## Contributors 🧞
|
||||
|
||||
@@ -15,37 +15,6 @@ services:
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=meet
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
healthcheck:
|
||||
test: [ "CMD", "mc", "ready", "local" ]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -71,9 +40,6 @@ services:
|
||||
- redis
|
||||
- nginx
|
||||
- livekit
|
||||
- createbuckets
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -107,7 +73,6 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
- livekit
|
||||
- minio
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
|
||||
|
Before Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -308,6 +308,7 @@ These are the environmental options available on meet backend.
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| RECORDING_ENABLE | record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | folder to store meetings | recordings |
|
||||
| RECORDING_VERIFY_SSL | verify ssl for recording storage | true |
|
||||
| RECORDING_WORKER_CLASSES | worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | enable storage event authorization | true |
|
||||
|
||||
@@ -12,20 +12,12 @@ PYTHONPATH=/app
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
DJANGO_EMAIL_BRAND_NAME=La Suite Numérique
|
||||
DJANGO_EMAIL_SUPPORT_EMAIL=test@yopmail.com
|
||||
DJANGO_EMAIL_LOGO_IMG=http://localhost:3000/assets/logo-suite-numerique.png
|
||||
DJANGO_EMAIL_DOMAIN=localhost:3000
|
||||
DJANGO_EMAIL_APP_BASE_URL=http://localhost:3000
|
||||
|
||||
# Backend url
|
||||
MEET_BASE_URL="http://localhost:8072"
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
@@ -48,9 +40,5 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
# Livekit Token settings
|
||||
LIVEKIT_API_SECRET=secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
|
||||
LIVEKIT_VERIFY_SSL=False
|
||||
LIVEKIT_API_URL=http://localhost:7880
|
||||
ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
|
||||
|
||||
@@ -40,7 +40,6 @@ def get_frontend_configuration(request):
|
||||
"recording": {
|
||||
"is_enabled": settings.RECORDING_ENABLE,
|
||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
},
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
|
||||
@@ -94,7 +94,7 @@ class HasAbilityPermission(IsAuthenticated):
|
||||
class HasPrivilegesOnRoom(IsAuthenticated):
|
||||
"""Check if user has privileges on a given room."""
|
||||
|
||||
message = "You must have privileges on room to perform this action."
|
||||
message = "You must have privileges to start a recording."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Determine if user has privileges on room."""
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
|
||||
@@ -12,11 +11,9 @@ from core import models, utils
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
timezone = TimeZoneSerializerField()
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
@@ -159,17 +156,7 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
fields = [
|
||||
"id",
|
||||
"room",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"status",
|
||||
"mode",
|
||||
"key",
|
||||
"is_expired",
|
||||
"expired_at",
|
||||
]
|
||||
fields = ["id", "room", "created_at", "updated_at", "status"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -202,11 +189,11 @@ class RequestEntrySerializer(serializers.Serializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("RequestEntrySerializer is validation-only")
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("RequestEntrySerializer is validation-only")
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class ParticipantEntrySerializer(serializers.Serializer):
|
||||
@@ -217,36 +204,8 @@ class ParticipantEntrySerializer(serializers.Serializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
|
||||
|
||||
|
||||
class CreationCallbackSerializer(serializers.Serializer):
|
||||
"""Validate room creation callback data."""
|
||||
|
||||
callback_id = serializers.CharField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("CreationCallbackSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("CreationCallbackSerializer is validation-only")
|
||||
|
||||
|
||||
class RoomInviteSerializer(serializers.Serializer):
|
||||
"""Validate room invite creation data."""
|
||||
|
||||
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("RoomInviteSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("RoomInviteSerializer is validation-only")
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -21,8 +20,7 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import enums, models, utils
|
||||
from core.recording.enums import FileExtension
|
||||
from core import models, utils
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
@@ -41,7 +39,6 @@ from core.recording.worker.factories import (
|
||||
from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
from core.services.invitation import InvitationService
|
||||
from core.services.livekit_events import (
|
||||
LiveKitEventsService,
|
||||
LiveKitWebhookError,
|
||||
@@ -50,7 +47,6 @@ from core.services.lobby import (
|
||||
LobbyParticipantNotFound,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
@@ -190,12 +186,6 @@ class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
scope = "request_entry"
|
||||
|
||||
|
||||
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
@@ -278,9 +268,6 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
if callback_id := self.request.data.get("callback_id"):
|
||||
RoomCreation().persist_callback_state(callback_id, room)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -366,7 +353,7 @@ class RoomViewSet(
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
methods=["POST"],
|
||||
url_path="request-entry",
|
||||
permission_classes=[],
|
||||
throttle_classes=[RequestEntryAnonRateThrottle],
|
||||
@@ -449,7 +436,7 @@ class RoomViewSet(
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
methods=["POST"],
|
||||
url_path="webhooks-livekit",
|
||||
permission_classes=[],
|
||||
)
|
||||
@@ -473,63 +460,6 @@ class RoomViewSet(
|
||||
{"status": "error", "message": str(e)}, status=status_code
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="creation-callback",
|
||||
permission_classes=[],
|
||||
throttle_classes=[CreationCallbackAnonRateThrottle],
|
||||
)
|
||||
def creation_callback(self, request):
|
||||
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
||||
|
||||
Designed for interoperability across iframes, popups, and other contexts,
|
||||
even on the same domain, bypassing browser security restrictions on direct communication.
|
||||
"""
|
||||
|
||||
serializer = serializers.CreationCallbackSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
room = RoomCreation().get_callback_state(
|
||||
callback_id=serializer.validated_data.get("callback_id")
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success", "room": room}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="invite",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
def invite(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Send email invitations to join a room.
|
||||
|
||||
This API endpoint allows a user with appropriate privileges to send email invitations
|
||||
to one or more recipients, inviting them to join the specified room.
|
||||
"""
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.RoomInviteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
emails = serializer.validated_data.get("emails")
|
||||
emails = list(set(emails))
|
||||
|
||||
InvitationService().invite_to_room(
|
||||
room=room, sender=request.user, emails=emails
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success", "message": "invitations sent"},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
@@ -579,7 +509,6 @@ class ResourceAccessViewSet(
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -653,85 +582,3 @@ class RecordingViewSet(
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
Raises PermissionDenied if the header is missing.
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
Based on the original url and the logged-in user, we must decide if we authorize Nginx
|
||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||
a 403 error). Note that we return 403 errors without any further details for security
|
||||
reasons.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
return urlparse(original_url)
|
||||
|
||||
def _auth_get_url_params(self, pattern, fragment):
|
||||
"""
|
||||
Extracts URL parameters from the given fragment using the specified regex pattern.
|
||||
Raises PermissionDenied if parameters cannot be extracted.
|
||||
"""
|
||||
|
||||
match = pattern.search(fragment)
|
||||
|
||||
try:
|
||||
return match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
def media_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to a recording's
|
||||
media file.
|
||||
When we let the request go through, we compute authorization headers that will be added to
|
||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||
annotation. The request will then be proxied to the object storage backend who will
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
|
||||
parsed_url = self._auth_get_original_url(request)
|
||||
|
||||
url_params = self._auth_get_url_params(
|
||||
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
|
||||
)
|
||||
|
||||
user = request.user
|
||||
recording_id = url_params["recording_id"]
|
||||
|
||||
extension = url_params["extension"]
|
||||
if extension not in [item.value for item in FileExtension]:
|
||||
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(id=recording_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||
|
||||
if extension != recording.extension:
|
||||
raise drf_exceptions.NotFound("No recording found with this extension.")
|
||||
|
||||
abilities = recording.get_abilities(user)
|
||||
|
||||
if not abilities["retrieve"]:
|
||||
logger.debug("User '%s' lacks permission for attachment", user.id)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
if not recording.is_saved:
|
||||
logger.debug("Recording '%s' has not been saved", recording)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
request = utils.generate_s3_authorization_headers(recording.key)
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@@ -2,21 +2,9 @@
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
|
||||
)
|
||||
|
||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||
# the choice of languages which should not be limited to the few languages active in
|
||||
# the app.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_alter_room_access_level'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
]
|
||||
@@ -3,9 +3,8 @@ Declare and configure the models for the Meet core application
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from logging import getLogger
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
@@ -13,15 +12,12 @@ from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.core import mail, validators
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from .recording.enums import FileExtension
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@@ -580,57 +576,6 @@ class Recording(BaseModel):
|
||||
RecordingStatusChoices.STOPPED,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_saved(self) -> bool:
|
||||
"""Check if the recording is in a saved state."""
|
||||
return self.status in {
|
||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||
RecordingStatusChoices.SAVED,
|
||||
}
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
"""Get recording extension based on its mode."""
|
||||
extensions = {
|
||||
RecordingModeChoices.TRANSCRIPT: FileExtension.OGG.value,
|
||||
RecordingModeChoices.SCREEN_RECORDING: FileExtension.MP4.value,
|
||||
}
|
||||
return extensions.get(self.mode, FileExtension.MP4.value)
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
"""Generate the file key based on recording mode."""
|
||||
|
||||
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
|
||||
|
||||
@property
|
||||
def expired_at(self) -> Optional[datetime]:
|
||||
"""
|
||||
Calculate the expiration date based on created_at and RECORDING_EXPIRATION_DAYS.
|
||||
Returns None if no expiration is configured.
|
||||
|
||||
Note: This is a naive and imperfect implementation since recordings are actually
|
||||
saved to the bucket after created_at timestamp is set. The actual expiration
|
||||
will be determined by the bucket lifecycle policy which operates on the object's
|
||||
timestamp in the storage system, not this value.
|
||||
"""
|
||||
|
||||
if not settings.RECORDING_EXPIRATION_DAYS:
|
||||
return None
|
||||
|
||||
return self.created_at + timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""
|
||||
Determine if the recording has expired by comparing expired_at with current UTC time.
|
||||
Returns False if no expiration is configured or if expiration date is in the future.
|
||||
"""
|
||||
if not self.expired_at:
|
||||
return False
|
||||
|
||||
return self.expired_at < timezone.now()
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Enums related to recordings."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class FileExtension(Enum):
|
||||
"""Enum for file extensions used in recordings."""
|
||||
|
||||
OGG = "ogg"
|
||||
MP4 = "mp4"
|
||||
@@ -1,13 +1,8 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
|
||||
@@ -26,7 +21,10 @@ class NotificationService:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
return self._notify_user_by_email(recording)
|
||||
logger.warning(
|
||||
"Screen recording mode not implemented for recording %s", recording.id
|
||||
)
|
||||
return False
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
@@ -35,79 +33,6 @@ class NotificationService:
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""
|
||||
Send an email notification to recording owners when their recording is ready.
|
||||
|
||||
The email includes a direct link that redirects owners to a dedicated download
|
||||
page in the frontend where they can access their specific recording.
|
||||
"""
|
||||
|
||||
owner_accesses = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
if not owner_accesses:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
context = {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_name": recording.room.name,
|
||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
|
||||
}
|
||||
|
||||
has_failures = False
|
||||
|
||||
# We process emails individually rather than in batch because:
|
||||
# 1. Each email requires personalization (timezone, language)
|
||||
# 2. The number of recipients per recording is typically small (not thousands)
|
||||
for access in owner_accesses:
|
||||
user = access.user
|
||||
language = user.language or get_language()
|
||||
with override(language):
|
||||
personalized_context = {
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%H:%M"),
|
||||
**context,
|
||||
}
|
||||
msg_html = render_to_string(
|
||||
"mail/html/screen_recording.html", personalized_context
|
||||
)
|
||||
msg_plain = render_to_string(
|
||||
"mail/text/screen_recording.txt", personalized_context
|
||||
)
|
||||
subject = str(_("Your recording is ready")) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[user.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
|
||||
return not has_failures
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
@@ -132,8 +57,10 @@ class NotificationService:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
|
||||
|
||||
payload = {
|
||||
"filename": recording.key,
|
||||
"filename": key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class WorkerServiceConfig:
|
||||
|
||||
output_folder: str
|
||||
server_configurations: Dict[str, Any]
|
||||
verify_ssl: Optional[bool]
|
||||
bucket_args: Optional[dict]
|
||||
|
||||
@classmethod
|
||||
@@ -28,6 +29,7 @@ class WorkerServiceConfig:
|
||||
return cls(
|
||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||
server_configurations=settings.LIVEKIT_CONFIGURATION,
|
||||
verify_ssl=settings.RECORDING_VERIFY_SSL,
|
||||
bucket_args={
|
||||
"endpoint": settings.AWS_S3_ENDPOINT_URL,
|
||||
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import aiohttp
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
from livekit.api.egress_service import EgressService
|
||||
|
||||
from ... import utils
|
||||
from ..enums import FileExtension
|
||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
|
||||
@@ -28,22 +28,21 @@ class BaseEgressService:
|
||||
async def _handle_request(self, request, method_name: str):
|
||||
"""Handle making a request to the LiveKit API and returns the response."""
|
||||
|
||||
lkapi = utils.create_livekit_client(self._config.server_configurations)
|
||||
# Use HTTP connector for local development with Tilt,
|
||||
# where cluster communications are unsecure
|
||||
connector = aiohttp.TCPConnector(ssl=self._config.verify_ssl)
|
||||
|
||||
# ruff: noqa: SLF001
|
||||
# pylint: disable=protected-access
|
||||
method = getattr(lkapi._egress, method_name)
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
client = EgressService(session, **self._config.server_configurations)
|
||||
method = getattr(client, method_name)
|
||||
try:
|
||||
response = await method(request)
|
||||
except livekit_api.TwirpError as e:
|
||||
raise WorkerConnectionError(
|
||||
f"LiveKit client connection error, {e.message}."
|
||||
) from e
|
||||
|
||||
try:
|
||||
response = await method(request)
|
||||
return response
|
||||
except livekit_api.TwirpError as e:
|
||||
raise WorkerConnectionError(
|
||||
f"LiveKit client connection error, {e.message}."
|
||||
) from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop an ongoing egress worker.
|
||||
@@ -90,9 +89,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
|
||||
# Save room's recording as a mp4 video file.
|
||||
file_type = livekit_api.EncodedFileType.MP4
|
||||
filepath = self._get_filepath(
|
||||
filename=recording_id, extension=FileExtension.MP4.value
|
||||
)
|
||||
filepath = self._get_filepath(filename=recording_id, extension="mp4")
|
||||
|
||||
file_output = livekit_api.EncodedFileOutput(
|
||||
file_type=file_type,
|
||||
@@ -101,7 +98,8 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
)
|
||||
|
||||
request = livekit_api.RoomCompositeEgressRequest(
|
||||
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
|
||||
room_name=room_name,
|
||||
file_outputs=[file_output],
|
||||
)
|
||||
|
||||
response = self._handle_request(request, "start_room_composite_egress")
|
||||
@@ -122,9 +120,7 @@ class AudioCompositeEgressService(BaseEgressService):
|
||||
|
||||
# Save room's recording as an ogg audio file.
|
||||
file_type = livekit_api.EncodedFileType.OGG
|
||||
filepath = self._get_filepath(
|
||||
filename=recording_id, extension=FileExtension.OGG.value
|
||||
)
|
||||
filepath = self._get_filepath(filename=recording_id, extension="ogg")
|
||||
|
||||
file_output = livekit_api.EncodedFileOutput(
|
||||
file_type=file_type,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Invitation Service."""
|
||||
|
||||
import smtplib
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class InvitationError(Exception):
|
||||
"""Exception raised when invitation emails cannot be sent."""
|
||||
|
||||
status_code = 500
|
||||
|
||||
|
||||
class InvitationService:
|
||||
"""Service for invitations to users."""
|
||||
|
||||
@staticmethod
|
||||
def invite_to_room(room, sender, emails):
|
||||
"""Send invitation emails to join a room."""
|
||||
|
||||
language = get_language()
|
||||
|
||||
context = {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_url": f"{settings.EMAIL_APP_BASE_URL}/{room.slug}",
|
||||
"room_link": f"{settings.EMAIL_DOMAIN}/{room.slug}",
|
||||
"sender_email": sender.email,
|
||||
}
|
||||
|
||||
with override(language):
|
||||
msg_html = render_to_string("mail/html/invitation.html", context)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", context)
|
||||
subject = str(
|
||||
_(
|
||||
f"Video call in progress: {sender.email} is waiting for you to connect"
|
||||
)
|
||||
) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error("invitation to %s was not sent: %s", emails, e)
|
||||
raise InvitationError("Could not send invitation") from e
|
||||
@@ -14,6 +14,7 @@ from django.core.cache import cache
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
ListRoomsRequest,
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
)
|
||||
@@ -346,7 +347,7 @@ class LobbyService:
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
}
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
try:
|
||||
room_response = await lkapi.room.list_rooms(
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Room creation service."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
class RoomCreation:
|
||||
"""Room creation related methods"""
|
||||
|
||||
@staticmethod
|
||||
def _get_cache_key(callback_id):
|
||||
"""Generate a standardized cache key for room creation callbacks."""
|
||||
return f"room-creation-callback_{callback_id}"
|
||||
|
||||
def persist_callback_state(self, callback_id: str, room) -> None:
|
||||
"""Store room data in cache using the callback ID as an identifier."""
|
||||
data = {
|
||||
"slug": room.slug,
|
||||
}
|
||||
cache.set(
|
||||
self._get_cache_key(callback_id),
|
||||
data,
|
||||
timeout=settings.ROOM_CREATION_CALLBACK_CACHE_TIMEOUT,
|
||||
)
|
||||
|
||||
def get_callback_state(self, callback_id: str) -> dict:
|
||||
"""Retrieve and clear cached room data for the given callback ID."""
|
||||
|
||||
cache_key = self._get_cache_key(callback_id)
|
||||
data = cache.get(cache_key)
|
||||
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
cache.delete(cache_key)
|
||||
|
||||
return data
|
||||
@@ -1,224 +0,0 @@
|
||||
"""
|
||||
Test event notification.
|
||||
"""
|
||||
|
||||
# pylint: disable=E1128,W0621,W0613,W0212
|
||||
|
||||
import datetime
|
||||
import smtplib
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.recording.event.notification import NotificationService, notification_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocked_current_site():
|
||||
"""Mocks the Site.objects.get_current()to return a controlled predefined domain."""
|
||||
|
||||
site_mock = mock.Mock()
|
||||
site_mock.domain = "test-domain.com"
|
||||
|
||||
with mock.patch.object(
|
||||
Site.objects, "get_current", return_value=site_mock
|
||||
) as patched:
|
||||
yield patched
|
||||
|
||||
|
||||
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
|
||||
def test_notify_external_services_transcript_mode(mock_notify_summary):
|
||||
"""Test notification routing for transcript mode recordings."""
|
||||
|
||||
service = NotificationService()
|
||||
|
||||
recording = factories.RecordingFactory(mode=models.RecordingModeChoices.TRANSCRIPT)
|
||||
result = service.notify_external_services(recording)
|
||||
|
||||
assert result is True
|
||||
mock_notify_summary.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
|
||||
def test_notify_external_services_screen_recording_mode(mock_notify_email):
|
||||
"""Test notification routing for screen recording mode."""
|
||||
|
||||
service = NotificationService()
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
mode=models.RecordingModeChoices.SCREEN_RECORDING
|
||||
)
|
||||
|
||||
result = service.notify_external_services(recording)
|
||||
|
||||
assert result is True
|
||||
mock_notify_email.assert_called_once_with(recording)
|
||||
|
||||
|
||||
def test_notify_external_services_unknown_mode(caplog):
|
||||
"""Test notification for unknown recording mode."""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
# Bypass validation
|
||||
recording.mode = "unknown"
|
||||
|
||||
service = NotificationService()
|
||||
|
||||
result = service.notify_external_services(recording)
|
||||
|
||||
assert result is False
|
||||
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
|
||||
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
"""Test successful email notification to recording owners."""
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
|
||||
# Fix recording test to avoid flaky tests
|
||||
recording.created_at = datetime.datetime(2023, 5, 15, 14, 30, 0)
|
||||
|
||||
french_user = factories.UserFactory(
|
||||
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
|
||||
)
|
||||
dutch_user = factories.UserFactory(
|
||||
email="berry@test.com", language="nl-nl", timezone="Europe/Amsterdam"
|
||||
)
|
||||
english_user = factories.UserFactory(
|
||||
email="john@test.com", language="en-us", timezone="America/Phoenix"
|
||||
)
|
||||
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=french_user
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=dutch_user
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=english_user
|
||||
)
|
||||
|
||||
# Create non-owner users to verify they don't receive emails
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.MEMBER
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.ADMIN
|
||||
)
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
assert mock_send_mail.call_count == 3
|
||||
|
||||
call_args_list = mock_send_mail.call_args_list
|
||||
|
||||
base_content = [
|
||||
"ACME", # Brand name
|
||||
"support@acme.com", # Support email
|
||||
"https://acme.com/logo", # Logo URL
|
||||
f"https://acme.com/recordings/{recording.id}", # Recording link
|
||||
"Conference Room A", # Room name
|
||||
]
|
||||
|
||||
# First call verification
|
||||
subject1, body1, sender1, recipients1 = call_args_list[0][0]
|
||||
assert subject1 == "Votre enregistrement est prêt"
|
||||
|
||||
# Verify email contains expected content
|
||||
personalized_content1 = [
|
||||
*base_content,
|
||||
"2023-05-15", # Formatted date
|
||||
"16:30", # Formatted time
|
||||
"Votre enregistrement est prêt !", # Intro
|
||||
]
|
||||
for content in personalized_content1:
|
||||
assert content in body1
|
||||
|
||||
assert recipients1 == ["franc@test.com"]
|
||||
assert sender1 == "notifications@acme.com"
|
||||
|
||||
# Second call verification
|
||||
subject2, body2, sender2, recipients2 = call_args_list[1][0]
|
||||
assert subject2 == "Je opname is klaar"
|
||||
|
||||
# Verify second email content (if needed)
|
||||
personalized_content2 = [
|
||||
*base_content,
|
||||
"2023-05-15", # Formatted date
|
||||
"16:30", # Formatted time
|
||||
"Je opname is klaar!", # Intro
|
||||
]
|
||||
for content in personalized_content2:
|
||||
assert content in body2
|
||||
|
||||
assert recipients2 == ["berry@test.com"]
|
||||
assert sender2 == "notifications@acme.com"
|
||||
|
||||
# Third call verification
|
||||
subject3, body3, sender3, recipients3 = call_args_list[2][0]
|
||||
assert subject3 == "Your recording is ready"
|
||||
|
||||
# Verify second email content (if needed)
|
||||
personalized_content3 = [
|
||||
*base_content,
|
||||
"Conference Room A", # Room name
|
||||
"2023-05-15", # Formatted date
|
||||
"07:30", # Formatted time
|
||||
"Your recording is ready!", # Intro
|
||||
]
|
||||
for content in personalized_content3:
|
||||
assert content in body3
|
||||
|
||||
assert recipients3 == ["john@test.com"]
|
||||
assert sender3 == "notifications@acme.com"
|
||||
|
||||
|
||||
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
|
||||
"""Test email notification when no owners are found."""
|
||||
|
||||
# Recording with no access
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
assert f"No owner found for recording {recording.id}" in caplog.text
|
||||
|
||||
|
||||
def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
"""Test email notification when an exception occurs."""
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.send_mail",
|
||||
side_effect=smtplib.SMTPException("SMTP Error"),
|
||||
) as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
assert mock_send_mail.call_count == 2
|
||||
assert "notification could not be sent:" in caplog.text
|
||||
@@ -22,37 +22,15 @@ def test_api_recordings_list_anonymous():
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_recordings_list_authenticated_no_recording():
|
||||
"""
|
||||
Authenticated users listing recordings should only
|
||||
see recordings to which they have access.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
factories.UserRecordingAccessFactory(user=other_user)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["administrator", "member", "owner"],
|
||||
)
|
||||
def test_api_recordings_list_authenticated_direct(role, settings):
|
||||
def test_api_recordings_list_authenticated_direct(role):
|
||||
"""
|
||||
Authenticated users listing recordings, should only see the recordings
|
||||
to which they are related.
|
||||
"""
|
||||
settings.RECORDING_EXPIRATIONS_DAYS = None
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -79,9 +57,7 @@ def test_api_recordings_list_authenticated_direct(role, settings):
|
||||
assert expected_ids == result_ids
|
||||
assert results[0] == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"mode": recording.mode,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
@@ -90,8 +66,6 @@ def test_api_recordings_list_authenticated_direct(role, settings):
|
||||
},
|
||||
"status": "initiated",
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
"""
|
||||
Test media-auth authorization API endpoint in docs core app.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import models
|
||||
from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_unauthenticated():
|
||||
"""
|
||||
Test that unauthenticated requests to download media are rejected
|
||||
"""
|
||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_wrong_path():
|
||||
"""
|
||||
Test that media URLs with incorrect path structures are rejected
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
original_url = f"http://localhost/media/wrong-path/{uuid4()!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_unknown_recording():
|
||||
"""
|
||||
Test that requests for non-existent recordings are properly handled
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_no_abilities():
|
||||
"""
|
||||
Test that users without any access permissions cannot download recordings
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_wrong_abilities():
|
||||
"""
|
||||
Test that users with insufficient role permissions cannot download recordings
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
|
||||
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="member")
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
|
||||
def test_api_recordings_media_auth_unsaved(wrong_status):
|
||||
"""
|
||||
Test that recordings that aren't in 'saved' status cannot be downloaded
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=wrong_status)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_mismatched_extension():
|
||||
"""
|
||||
Test that requests with mismatched file extensions are rejected
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(
|
||||
status=models.RecordingStatusChoices.SAVED,
|
||||
mode=models.RecordingModeChoices.TRANSCRIPT,
|
||||
)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No recording found with this extension."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
|
||||
)
|
||||
def test_api_recordings_media_auth_wrong_extension(wrong_extension):
|
||||
"""
|
||||
Trying to download a recording with an unsupported extension should return
|
||||
a validation error (400) with details about allowed extensions.
|
||||
"""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
original_url = (
|
||||
f"http://localhost/media/recordings/{recording.id!s}.{wrong_extension}"
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Unsupported extension."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
|
||||
def test_api_recordings_media_auth_success_owner(mode):
|
||||
"""
|
||||
Test downloading a recording when logged in and authorized.
|
||||
Verifies S3 authentication headers and successful file retrieval.
|
||||
"""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
|
||||
def test_api_recordings_media_auth_success_administrator(mode):
|
||||
"""
|
||||
Test downloading a recording when logged in and authorized.
|
||||
Verifies S3 authentication headers and successful file retrieval.
|
||||
"""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="administrator")
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
@@ -1,243 +0,0 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
from ...models import RecordingStatusChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recording_retrieve_anonymous():
|
||||
"""Anonymous users should not be able to retrieve recordings."""
|
||||
recording = RecordingFactory()
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_authenticated():
|
||||
"""Authenticated users without access receive 404 when requesting recordings.
|
||||
|
||||
The API returns 404 instead of 403 to avoid revealing the existence of
|
||||
resources the user doesn't have permission to access.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = UserFactory()
|
||||
recording = UserRecordingAccessFactory(user=other_user).recording
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Recording matches the given query."}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_members():
|
||||
"""
|
||||
A user who is a member of a recording should not be able to retrieve it.
|
||||
"""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_administrators(settings):
|
||||
"""A user who is an administrator of a recording should be able to retrieve it."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = None
|
||||
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="administrator")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_owners(settings):
|
||||
"""A user who is an owner of a recording should be able to retrieve it."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = None
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
|
||||
"""Test that the API returns the correct expiration date for a non-expired recording."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = 1
|
||||
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(
|
||||
recording=recording, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": "2023-01-15T12:00:00Z",
|
||||
"updated_at": "2023-01-15T12:00:00Z",
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": "2023-01-16T12:00:00Z",
|
||||
"is_expired": False, # Ensure the recording is still valid and hasn't expired
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_expired(settings):
|
||||
"""Test that the API returns the correct expiration date and flag for an expired recording."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = 2
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
with freeze_time("2023-01-15 12:00:00"):
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(
|
||||
recording=recording, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": "2023-01-15T12:00:00Z",
|
||||
"updated_at": "2023-01-15T12:00:00Z",
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": "2023-01-17T12:00:00Z",
|
||||
"is_expired": True, # Ensure the recording has expired
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.INITIATED,
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
def test_api_recording_retrieve_any_status(status):
|
||||
"""Test that recordings with any status can be retrieved."""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["id"] == str(recording.id)
|
||||
assert content["status"] == status
|
||||
@@ -32,6 +32,7 @@ def test_settings():
|
||||
mocked_settings = {
|
||||
"RECORDING_OUTPUT_FOLDER": "/test/output",
|
||||
"LIVEKIT_CONFIGURATION": {"server": "test.example.com"},
|
||||
"RECORDING_VERIFY_SSL": True,
|
||||
"AWS_S3_ENDPOINT_URL": "https://s3.test.com",
|
||||
"AWS_S3_ACCESS_KEY_ID": "test_key",
|
||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
||||
@@ -55,6 +56,7 @@ def test_config_initialization(default_config):
|
||||
"""Test that WorkerServiceConfig is properly initialized from settings"""
|
||||
assert default_config.output_folder == "/test/output"
|
||||
assert default_config.server_configurations == {"server": "test.example.com"}
|
||||
assert default_config.verify_ssl is True
|
||||
assert default_config.bucket_args == {
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
@@ -74,6 +76,7 @@ def test_config_immutability(default_config):
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
RECORDING_VERIFY_SSL=True,
|
||||
AWS_S3_ENDPOINT_URL="https://s3.test.com",
|
||||
AWS_S3_ACCESS_KEY_ID="test_key",
|
||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||
|
||||
@@ -6,9 +6,10 @@ Test worker service classes.
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from core.recording.worker.exceptions import WorkerResponseError
|
||||
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from core.recording.worker.factories import WorkerServiceConfig
|
||||
from core.recording.worker.services import (
|
||||
AudioCompositeEgressService,
|
||||
@@ -24,10 +25,11 @@ def config():
|
||||
return WorkerServiceConfig(
|
||||
output_folder="/test/output",
|
||||
server_configurations={
|
||||
"url": "test.livekit.io",
|
||||
"host": "test.livekit.io",
|
||||
"api_key": "test_key",
|
||||
"api_secret": "test_secret",
|
||||
},
|
||||
verify_ssl=True,
|
||||
bucket_args={
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
@@ -125,6 +127,58 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
|
||||
assert result.endswith(f"{filename}.{extension}")
|
||||
|
||||
|
||||
def test_base_egress_handle_request_success(
|
||||
config, service, mock_client_session, mock_egress_service, mock_tcp_connector
|
||||
):
|
||||
"""Test successful request handling"""
|
||||
# Setup mock response
|
||||
mock_response = Mock()
|
||||
mock_method = AsyncMock(return_value=mock_response)
|
||||
mock_egress_instance = Mock()
|
||||
mock_egress_instance.test_method = mock_method
|
||||
mock_egress_service.return_value = mock_egress_instance
|
||||
|
||||
# Create test request
|
||||
test_request = Mock()
|
||||
|
||||
response = service._handle_request(test_request, "test_method")
|
||||
|
||||
mock_client_session.assert_called_once_with(
|
||||
connector=mock_tcp_connector.return_value
|
||||
)
|
||||
|
||||
# Verify EgressService initialization
|
||||
mock_egress_service.assert_called_once_with(
|
||||
mock_client_session.return_value.__aenter__.return_value,
|
||||
**service._config.server_configurations,
|
||||
)
|
||||
|
||||
# Verify method call and response
|
||||
mock_method.assert_called_once_with(test_request)
|
||||
assert response == mock_response
|
||||
|
||||
|
||||
def test_base_egress_handle_request_connection_error(service, mock_egress_service):
|
||||
"""Test handling of connection errors"""
|
||||
# Setup mock error
|
||||
mock_method = AsyncMock(
|
||||
side_effect=livekit_api.TwirpError(msg="Connection failed", code=500)
|
||||
)
|
||||
mock_egress_instance = Mock()
|
||||
mock_egress_instance.test_method = mock_method
|
||||
mock_egress_service.return_value = mock_egress_instance
|
||||
|
||||
# Create test request
|
||||
test_request = Mock()
|
||||
|
||||
# Verify error handling
|
||||
with pytest.raises(WorkerConnectionError) as exc:
|
||||
service._handle_request(test_request, "test_method")
|
||||
|
||||
assert "LiveKit client connection error" in str(exc.value)
|
||||
assert "Connection failed" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_status,expected_result",
|
||||
[
|
||||
@@ -170,6 +224,43 @@ def test_base_egress_start_not_implemented(service):
|
||||
assert "Subclass must implement this method" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verify_ssl", [True, False])
|
||||
def test_base_egress_ssl_verification_config(verify_ssl):
|
||||
"""Test SSL verification configuration"""
|
||||
config = WorkerServiceConfig(
|
||||
output_folder="/test/output",
|
||||
server_configurations={
|
||||
"host": "test.livekit.io",
|
||||
"api_key": "test_key",
|
||||
"api_secret": "test_secret",
|
||||
},
|
||||
verify_ssl=verify_ssl,
|
||||
bucket_args={
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
"secret": "test_secret",
|
||||
"region": "test-region",
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
},
|
||||
)
|
||||
|
||||
service = BaseEgressService(config)
|
||||
|
||||
# Mock ClientSession to capture connector configuration
|
||||
with patch("aiohttp.ClientSession") as mock_session:
|
||||
mock_session.return_value.__aenter__ = AsyncMock()
|
||||
mock_session.return_value.__aexit__ = AsyncMock()
|
||||
|
||||
# Trigger request to verify connector configuration
|
||||
service._handle_request(Mock(), "test_method")
|
||||
|
||||
# Verify SSL configuration
|
||||
connector = mock_session.call_args[1]["connector"]
|
||||
assert isinstance(connector, aiohttp.TCPConnector)
|
||||
assert connector._ssl == verify_ssl
|
||||
|
||||
|
||||
def test_video_composite_egress_hrid(video_service):
|
||||
"""Test HRID is correct"""
|
||||
assert video_service.hrid == "video-recording-composite-livekit-egress"
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -14,15 +11,6 @@ from ...models import Room
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_cache():
|
||||
"""Provide cache cleanup after each test to maintain test isolation."""
|
||||
yield
|
||||
keys = cache.keys("room-creation-callback_*")
|
||||
if keys:
|
||||
cache.delete(*keys)
|
||||
|
||||
|
||||
def test_api_rooms_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create rooms."""
|
||||
client = APIClient()
|
||||
@@ -38,7 +26,7 @@ def test_api_rooms_create_anonymous():
|
||||
assert Room.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated(reset_cache):
|
||||
def test_api_rooms_create_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to create rooms and should automatically be declared
|
||||
as owner of the newly created room.
|
||||
@@ -61,33 +49,6 @@ def test_api_rooms_create_authenticated(reset_cache):
|
||||
assert room.slug == "my-room"
|
||||
assert room.accesses.filter(role="owner", user=user).exists() is True
|
||||
|
||||
rooms_data = cache.keys("room-creation-callback_*")
|
||||
assert not rooms_data
|
||||
|
||||
|
||||
def test_api_rooms_create_generation_cache(reset_cache):
|
||||
"""
|
||||
Authenticated users creating a room with a callback ID should have room data stored in cache.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{"name": "my room", "callback_id": "1234"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.name == "my room"
|
||||
assert room.slug == "my-room"
|
||||
assert room.accesses.filter(role="owner", user=user).exists() is True
|
||||
|
||||
room_data = cache.get("room-creation-callback_1234")
|
||||
assert room_data.get("slug") == "my-room"
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_existing_slug():
|
||||
"""
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: creation callback functionality.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Tests for creation_callback endpoint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_cache():
|
||||
"""Provide cache cleanup after each test to maintain test isolation."""
|
||||
yield
|
||||
keys = cache.keys("room-creation-callback_*")
|
||||
if keys:
|
||||
cache.delete(*keys)
|
||||
|
||||
|
||||
def test_api_rooms_create_anonymous(reset_cache):
|
||||
"""Anonymous user can retrieve room data once using a valid callback ID."""
|
||||
client = APIClient()
|
||||
cache.set("room-creation-callback_123", {"slug": "my room"})
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
|
||||
|
||||
# Data should be cleared after retrieval
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {}}
|
||||
|
||||
|
||||
def test_api_rooms_create_empty_cache():
|
||||
"""Valid callback ID return empty room data when nothing is stored in the cache."""
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {}}
|
||||
|
||||
|
||||
def test_api_rooms_create_missing_callback_id():
|
||||
"""Requests without a callback ID properly fail with a 400 status code."""
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated(reset_cache):
|
||||
"""Authenticated users can also successfully retrieve room data using a valid callback ID"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
cache.set("room-creation-callback_123", {"slug": "my room"})
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
|
||||
@@ -1,295 +0,0 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: invite.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
import json
|
||||
import random
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...services.invitation import InvitationError, InvitationService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_rooms_invite_anonymous():
|
||||
"""Test anonymous users should not be allowed to invite people to rooms."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
data = {"emails": ["toto@yopmail.com"]}
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_invite_no_access():
|
||||
"""Test non-privileged users should not be allowed to invite people to rooms."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["toto@yopmail.com"]}
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You must have privileges on room to perform this action.",
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_invite_member():
|
||||
"""Test member users should not be allowed to invite people to rooms."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
room.accesses.create(user=user, role="member")
|
||||
|
||||
data = {"emails": ["toto@yopmail.com"]}
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You must have privileges on room to perform this action.",
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_invite_missing_emails():
|
||||
"""Test missing email list should return validation error."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"foo": []}
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"emails": [
|
||||
"This field is required.",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_invite_empty_emails():
|
||||
"""Test empty email list should return validation error."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": []}
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"emails": [
|
||||
"This list may not be empty.",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_invite_invalid_emails():
|
||||
"""Test invalid email addresses should return validation errors."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["abdc", "efg"]}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"emails": {
|
||||
"0": ["Enter a valid email address."],
|
||||
"1": ["Enter a valid email address."],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_invite_partially_invalid_emails():
|
||||
"""Test partially invalid email addresses should return validation errors."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["fabrice@yopmail.com", "efg"]}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"emails": {
|
||||
"1": ["Enter a valid email address."],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(InvitationService, "invite_to_room")
|
||||
def test_api_rooms_invite_duplicates(mock_invite_to_room):
|
||||
"""Test duplicate emails should be deduplicated before processing."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com", "Toto@yopmail.com"]}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_invite_to_room.assert_called_once()
|
||||
|
||||
_, kwargs = mock_invite_to_room.call_args
|
||||
|
||||
assert kwargs["room"] == room
|
||||
assert kwargs["sender"] == user
|
||||
assert sorted(kwargs["emails"]) == sorted(["Toto@yopmail.com", "toto@yopmail.com"])
|
||||
|
||||
|
||||
@mock.patch.object(InvitationService, "invite_to_room", side_effect=InvitationError())
|
||||
def test_api_rooms_invite_error(mock_invite_to_room):
|
||||
"""Test invitation service error should return appropriate error response."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com"]}
|
||||
|
||||
with pytest.raises(InvitationError) as excinfo:
|
||||
client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
mock_invite_to_room.assert_called_once()
|
||||
assert "Could not send invitation" in str(excinfo.value)
|
||||
|
||||
|
||||
@mock.patch("core.services.invitation.send_mail")
|
||||
def test_api_rooms_invite_success(mock_send_mail, settings):
|
||||
"""Test privileged users should successfully send invitation emails."""
|
||||
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.EMAIL_APP_BASE_URL = "https://acme.com"
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
settings.EMAIL_DOMAIN = "acme.com"
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "message": "invitations sent"}
|
||||
|
||||
mock_send_mail.assert_called_once()
|
||||
|
||||
subject, body, sender, recipients = mock_send_mail.call_args[0]
|
||||
|
||||
assert (
|
||||
subject == f"Video call in progress: {user.email} is waiting for you to connect"
|
||||
)
|
||||
|
||||
# Verify email contains expected content
|
||||
required_content = [
|
||||
"ACME", # Brand name
|
||||
"https://acme.com/logo", # Logo URL
|
||||
f"https://acme.com/{room.slug}", # Room url
|
||||
f"acme.com/{room.slug}", # Room link
|
||||
]
|
||||
|
||||
for content in required_content:
|
||||
assert content in body
|
||||
|
||||
assert sender == "notifications@acme.com"
|
||||
|
||||
# Verify all owners received the email (order-independent comparison)
|
||||
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
|
||||
@@ -336,11 +336,10 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
|
||||
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
"""
|
||||
Users who are members of a room should be allowed to see related users.
|
||||
"""
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = UserFactory()
|
||||
other_user = UserFactory()
|
||||
|
||||
@@ -370,8 +369,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
@@ -383,8 +380,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": other_user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
@@ -420,14 +415,11 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_administrators(
|
||||
mock_token, django_assert_num_queries, settings
|
||||
):
|
||||
def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries):
|
||||
"""
|
||||
A user who is an administrator or owner of a room should be allowed
|
||||
to see related users.
|
||||
"""
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = UserFactory()
|
||||
other_user = UserFactory()
|
||||
room = RoomFactory()
|
||||
@@ -456,8 +448,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": other_user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
@@ -469,8 +459,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
|
||||
@@ -776,10 +776,9 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
|
||||
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
@@ -795,11 +794,15 @@ def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_s
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Act
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Assert
|
||||
# Verify the API was initialized with correct configuration
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
@@ -810,8 +813,8 @@ def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_s
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
|
||||
@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
|
||||
@@ -827,11 +830,14 @@ def test_notify_participants_success(mock_create_livekit_client, lobby_service):
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
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 that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
@@ -849,15 +855,15 @@ def test_notify_participants_success(mock_create_livekit_client, lobby_service):
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
|
||||
@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, status=123)
|
||||
side_effect=TwirpError(msg="test error", code=123)
|
||||
)
|
||||
|
||||
class MockResponse:
|
||||
@@ -868,7 +874,7 @@ def test_notify_participants_error(mock_create_livekit_client, lobby_service):
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Call the function and expect an exception
|
||||
with pytest.raises(
|
||||
@@ -876,6 +882,9 @@ def test_notify_participants_error(mock_create_livekit_client, lobby_service):
|
||||
):
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
|
||||
@@ -103,10 +103,8 @@ def test_api_users_retrieve_me_anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_authenticated(settings):
|
||||
def test_api_users_retrieve_me_authenticated():
|
||||
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
|
||||
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -123,8 +121,6 @@ def test_api_users_retrieve_me_authenticated(settings):
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"short_name": user.short_name,
|
||||
"language": user.language,
|
||||
"timezone": "UTC",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ from core.factories import (
|
||||
UserFactory,
|
||||
UserRecordingAccessFactory,
|
||||
)
|
||||
from core.models import Recording, RecordingModeChoices, RecordingStatusChoices
|
||||
from core.recording.enums import FileExtension
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -217,74 +216,3 @@ def test_models_recording_worker_id_very_long():
|
||||
too_long_id = "w" * 256
|
||||
with pytest.raises(ValidationError):
|
||||
RecordingFactory(worker_id=too_long_id)
|
||||
|
||||
|
||||
# Test key property method
|
||||
|
||||
|
||||
def test_models_recording_key_for_transcript(settings):
|
||||
"""Test key property returns correct path for transcript mode."""
|
||||
|
||||
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
|
||||
|
||||
recording = RecordingFactory(mode=RecordingModeChoices.TRANSCRIPT)
|
||||
expected_path = f"/custom/path/{recording.id}.{FileExtension.OGG.value}"
|
||||
assert recording.key == expected_path
|
||||
|
||||
|
||||
def test_models_recording_key_for_screen_recording(settings):
|
||||
"""Test key property returns correct path for screen recording mode."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
|
||||
|
||||
recording = RecordingFactory(mode=RecordingModeChoices.SCREEN_RECORDING)
|
||||
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
|
||||
assert recording.key == expected_path
|
||||
|
||||
|
||||
def test_models_recording_key_for_unknown_mode(settings):
|
||||
"""Test key property uses default extension for unknown mode."""
|
||||
|
||||
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
|
||||
recording = RecordingFactory()
|
||||
# Directly set an invalid mode (bypassing validation)
|
||||
recording.mode = "unknown_mode"
|
||||
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
|
||||
assert recording.key == expected_path
|
||||
|
||||
|
||||
# Test is_saved method
|
||||
|
||||
|
||||
def test_models_recording_is_saved_true():
|
||||
"""Test is_saved property returns True for SAVED and NOTIFICATION_SUCCEEDED status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||
assert recording.is_saved is True
|
||||
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.NOTIFICATION_SUCCEEDED)
|
||||
assert recording.is_saved is True
|
||||
|
||||
|
||||
def test_models_recording_is_saved_false_active():
|
||||
"""Test is_saved property returns False for ACTIVE status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
|
||||
assert recording.is_saved is False
|
||||
|
||||
|
||||
def test_models_recording_is_saved_false_initiated():
|
||||
"""Test is_saved property returns False for INITIATED status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
|
||||
assert recording.is_saved is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
def test_models_recording_is_saved_false_error_states(status):
|
||||
"""Test is_saved property returns False for error statuses."""
|
||||
recording = RecordingFactory(status=status)
|
||||
assert recording.is_saved is False
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"""
|
||||
Test utils functions
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from core.utils import create_livekit_client
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_ssl_enabled(
|
||||
mock_livekit_api, mock_get_running_loop, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with SSL verification enabled."""
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
settings.LIVEKIT_VERIFY_SSL = True
|
||||
|
||||
create_livekit_client()
|
||||
|
||||
mock_livekit_api.assert_called_once_with(
|
||||
**settings.LIVEKIT_CONFIGURATION, session=None
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.aiohttp.ClientSession")
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_ssl_disabled(
|
||||
mock_livekit_api, mock_get_running_loop, mock_client_session, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with SSL verification disabled."""
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
mock_session_instance = mock.MagicMock()
|
||||
mock_client_session.return_value = mock_session_instance
|
||||
settings.LIVEKIT_VERIFY_SSL = False
|
||||
|
||||
create_livekit_client()
|
||||
|
||||
mock_livekit_api.assert_called_once_with(
|
||||
**settings.LIVEKIT_CONFIGURATION, session=mock_session_instance
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_custom_configuration(
|
||||
mock_livekit_api, mock_get_running_loop, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with custom configuration."""
|
||||
settings.LIVEKIT_VERIFY_SSL = True
|
||||
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
custom_configuration = {
|
||||
"api_key": "mock_key",
|
||||
"api_secret": "mock_secret",
|
||||
"url": "http://mock-url.com",
|
||||
}
|
||||
|
||||
create_livekit_client(custom_configuration)
|
||||
|
||||
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
|
||||
@@ -11,11 +11,8 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
import botocore
|
||||
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
|
||||
from livekit.api import AccessToken, VideoGrants
|
||||
|
||||
|
||||
def generate_color(identity: str) -> str:
|
||||
@@ -113,48 +110,3 @@ def generate_livekit_config(
|
||||
room=room_id, user=user, username=username, color=color
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
"""
|
||||
Generate authorization headers for an s3 object.
|
||||
These headers can be used as an alternative to signed urls with many benefits:
|
||||
- the urls of our files never expire and can be stored in our recording' metadata
|
||||
- we don't leak authorized urls that could be shared (file access can only be done
|
||||
with cookies)
|
||||
- access control is truly realtime
|
||||
- the object storage service does not need to be exposed on internet
|
||||
"""
|
||||
|
||||
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
ExpiresIn=0,
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
)
|
||||
|
||||
request = botocore.awsrequest.AWSRequest(method="get", url=url)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
# pylint: disable=protected-access
|
||||
credentials = s3_client._request_signer._credentials # noqa: SLF001
|
||||
frozen_credentials = credentials.get_frozen_credentials()
|
||||
region = s3_client.meta.region_name
|
||||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
|
||||
|
||||
def create_livekit_client(custom_configuration=None):
|
||||
"""Create and return a configured LiveKit API client."""
|
||||
|
||||
custom_session = None
|
||||
|
||||
if not settings.LIVEKIT_VERIFY_SSL:
|
||||
connector = aiohttp.TCPConnector(ssl=False)
|
||||
custom_session = aiohttp.ClientSession(connector=connector)
|
||||
|
||||
# Use default configuration if none provided
|
||||
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
|
||||
|
||||
return LiveKitAPI(session=custom_session, **configuration)
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
|
||||
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,433 +17,192 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:26
|
||||
#: core/admin.py:31
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:39
|
||||
#: core/admin.py:33
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:51
|
||||
#: core/admin.py:45
|
||||
msgid "Important dates"
|
||||
msgstr "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
#: core/api/serializers.py:128
|
||||
msgid "Markdown Body"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:77
|
||||
#: core/authentication.py:71
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr "User account is disabled"
|
||||
#: core/authentication.py:91
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr "Multiple user accounts share a common email."
|
||||
|
||||
#: core/models.py:30
|
||||
#: core/models.py:27
|
||||
msgid "Member"
|
||||
msgstr "Member"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:31
|
||||
#: core/models.py:28
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:32
|
||||
#: core/models.py:29
|
||||
msgid "Owner"
|
||||
msgstr "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:41
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:42
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:48
|
||||
msgid "Initiated"
|
||||
msgstr "Initiated"
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:49
|
||||
msgid "Active"
|
||||
msgstr "Active"
|
||||
|
||||
#: core/models.py:50
|
||||
msgid "Stopped"
|
||||
msgstr "Stopped"
|
||||
|
||||
#: core/models.py:51
|
||||
msgid "Saved"
|
||||
msgstr "Saved"
|
||||
|
||||
#: core/models.py:52
|
||||
msgid "Aborted"
|
||||
msgstr "Aborted"
|
||||
|
||||
#: core/models.py:53
|
||||
msgid "Failed to Start"
|
||||
msgstr "Failed to Start"
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:54
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Failed to Stop"
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:55
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification succeeded"
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:82
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCREEN_RECORDING"
|
||||
#: core/models.py:75
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:81
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:83
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:89
|
||||
msgid "Public Access"
|
||||
msgstr "Public Access"
|
||||
|
||||
#: core/models.py:90
|
||||
msgid "Trusted Access"
|
||||
msgstr "Trusted Access"
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:91
|
||||
msgid "Restricted Access"
|
||||
msgstr "Restricted Access"
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:96
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:103
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:104
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primary key for the record as UUID"
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:110
|
||||
msgid "created on"
|
||||
msgstr "created on"
|
||||
|
||||
#: core/models.py:111
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date and time at which a record was created"
|
||||
|
||||
#: core/models.py:116
|
||||
msgid "updated on"
|
||||
msgstr "updated on"
|
||||
|
||||
#: core/models.py:117
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date and time at which a record was last updated"
|
||||
|
||||
#: core/models.py:137
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
|
||||
#: core/models.py:143
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:145
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
#: core/models.py:113
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
|
||||
#: core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "identity email address"
|
||||
#: core/models.py:115
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "admin email address"
|
||||
#: core/models.py:118
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
msgid "full name"
|
||||
msgstr "full name"
|
||||
#: core/models.py:120
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:123
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:126
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:139
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:162
|
||||
msgid "short name"
|
||||
msgstr "short name"
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:163
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:164
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:166
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "language"
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "The language in which the user wants to see the interface."
|
||||
#: core/models.py:174
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "The timezone in which the user wants to see times."
|
||||
|
||||
#: core/models.py:178
|
||||
msgid "device"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:180
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Whether the user is a device or a real user."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:185
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Whether the user can log into this admin site."
|
||||
|
||||
#: core/models.py:188
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:191
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
|
||||
#: core/models.py:204
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:205
|
||||
msgid "users"
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:264
|
||||
msgid "Resource"
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:265
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:319
|
||||
msgid "Resource access"
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:320
|
||||
msgid "Resource accesses"
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:326
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Resource access with this User and Resource already exists."
|
||||
|
||||
#: core/models.py:382
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:383
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Values for Visio parameters to configure the room."
|
||||
|
||||
#: core/models.py:389 core/models.py:509
|
||||
msgid "Room"
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:390
|
||||
msgid "Rooms"
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:520
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:522
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
#: core/models.py:256
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:530
|
||||
msgid "Recording mode"
|
||||
msgstr "Recording mode"
|
||||
#: core/models.py:257
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:531
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
#: core/models.py:263
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:537
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
#: core/models.py:269
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:538
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:646
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:647
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:653
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:659
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:665
|
||||
#: core/models.py:275
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr "Authentication is enabled but token is not configured."
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr "Authorization header is required"
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr "Invalid authorization header."
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr "Invalid token"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Your recording is ready"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logo email"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "invites you to join an ongoing video call"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "JOIN THE CALL"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
msgstr ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Tips for a better experience:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Use Chrome or Firefox for better call quality"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Test your microphone and camera before joining"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Make sure you have a stable internet connection"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Thank you for using %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "Your recording is ready!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " The recording will expire in %(days)s days. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "To keep this recording permanently:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Click the \"Open\" button below "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Use the \"Download\" button in the interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Save the file to your preferred location"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
msgstr ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
#: meet/settings.py:134
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:135
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
msgstr ""
|
||||
|
||||
@@ -8,450 +8,201 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
|
||||
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:26
|
||||
#: core/admin.py:31
|
||||
msgid "Personal info"
|
||||
msgstr "Informations personnelles"
|
||||
|
||||
#: core/admin.py:39
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:51
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/authentication/backends.py:77
|
||||
#: core/admin.py:33
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:45
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:128
|
||||
msgid "Markdown Body"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:71
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
"Les informations utilisateur ne contiennent aucune identification "
|
||||
"utilisateur reconnaissable"
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr "Le compte utilisateur est désactivé"
|
||||
#: core/authentication.py:91
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr "Plusieurs comptes utilisateur partagent une adresse e-mail commune."
|
||||
|
||||
#: core/models.py:30
|
||||
#: core/models.py:27
|
||||
msgid "Member"
|
||||
msgstr "Membre"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:31
|
||||
#: core/models.py:28
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:32
|
||||
#: core/models.py:29
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:41
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:42
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:48
|
||||
msgid "Initiated"
|
||||
msgstr "Initié"
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:49
|
||||
msgid "Active"
|
||||
msgstr "Actif"
|
||||
|
||||
#: core/models.py:50
|
||||
msgid "Stopped"
|
||||
msgstr "Arrêté"
|
||||
|
||||
#: core/models.py:51
|
||||
msgid "Saved"
|
||||
msgstr "Enregistré"
|
||||
|
||||
#: core/models.py:52
|
||||
msgid "Aborted"
|
||||
msgstr "Abandonné"
|
||||
|
||||
#: core/models.py:53
|
||||
msgid "Failed to Start"
|
||||
msgstr "Échec au démarrage"
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:54
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Échec à l'arrêt"
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:55
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification réussie"
|
||||
|
||||
#: core/models.py:82
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||
|
||||
#: core/models.py:83
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPTION"
|
||||
|
||||
#: core/models.py:89
|
||||
msgid "Public Access"
|
||||
msgstr "Accès public"
|
||||
|
||||
#: core/models.py:90
|
||||
msgid "Trusted Access"
|
||||
msgstr "Accès de confiance"
|
||||
|
||||
#: core/models.py:91
|
||||
msgid "Restricted Access"
|
||||
msgstr "Accès restreint"
|
||||
|
||||
#: core/models.py:103
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:104
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||
|
||||
#: core/models.py:110
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:111
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||
|
||||
#: core/models.py:116
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:117
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||
"fois"
|
||||
|
||||
#: core/models.py:137
|
||||
#: core/models.py:75
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Entrez un identifiant valide. Cette valeur ne peut contenir que des lettres, "
|
||||
"des chiffres et les caractères @/./+/-/_."
|
||||
|
||||
#: core/models.py:143
|
||||
#: core/models.py:81
|
||||
msgid "sub"
|
||||
msgstr "identifiant"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:145
|
||||
#: core/models.py:83
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
|
||||
"+/-/_ uniquement."
|
||||
|
||||
#: core/models.py:153
|
||||
#: core/models.py:91
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:158
|
||||
#: core/models.py:96
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:162
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:103
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:169
|
||||
#: core/models.py:104
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
#: core/models.py:110
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:178
|
||||
#: core/models.py:113
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:115
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:118
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:185
|
||||
#: core/models.py:120
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:123
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:191
|
||||
#: core/models.py:126
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||
"au lieu de supprimer des comptes."
|
||||
|
||||
#: core/models.py:204
|
||||
#: core/models.py:138
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:139
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:264
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:265
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:319
|
||||
msgid "Resource access"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:320
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:326
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||
|
||||
#: core/models.py:382
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:383
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||
|
||||
#: core/models.py:389 core/models.py:509
|
||||
msgid "Room"
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:390
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:520
|
||||
msgid "Worker ID"
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:522
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
#: core/models.py:161
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
"Entrez un identifiant pour l'enregistrement du travailleur. Cet identifiant "
|
||||
"est conservé même lorsque le travailleur s'arrête, permettant un suivi "
|
||||
"facile."
|
||||
|
||||
#: core/models.py:530
|
||||
msgid "Recording mode"
|
||||
msgstr "Mode d'enregistrement"
|
||||
#: core/models.py:162
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:531
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
#: core/models.py:163
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:537
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
#: core/models.py:164
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:538
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
#: core/models.py:166
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:646
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
#: core/models.py:168
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:647
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
#: core/models.py:174
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:653
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
#: core/models.py:175
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:659
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
#: core/models.py:256
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:665
|
||||
#: core/models.py:257
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:263
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:269
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:275
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr "L'authentification est activée mais le jeton n'est pas configuré."
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr "L'en-tête d'autorisation est requis"
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr "En-tête d'autorisation invalide."
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr "Jeton invalide"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Votre enregistrement est prêt"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logo email"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "vous invite à rejoindre un appel vidéo en cours"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "REJOINDRE L'APPEL"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
msgstr ""
|
||||
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
|
||||
"votre navigateur pour rejoindre l'appel."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Conseils pour une meilleure expérience :"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Testez votre microphone et votre caméra avant de rejoindre"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Assurez-vous d'avoir une connexion Internet stable"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Merci d'utiliser %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "Votre enregistrement est prêt !"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
|
||||
"%(recording_time)s est maintenant prêt à être téléchargé. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " L'enregistrement expirera dans %(days)s jours. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
msgstr ""
|
||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||
"équipe d'assistance à %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
#: meet/settings.py:134
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:135
|
||||
msgid "French"
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
msgstr ""
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"Language: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke informatie"
|
||||
|
||||
#: core/admin.py:39
|
||||
msgid "Permissions"
|
||||
msgstr "Rechten"
|
||||
|
||||
#: core/admin.py:51
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr "Gebruikersinformatie bevatte geen herkenbare gebruikersidentificatie"
|
||||
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr "Gebruikersaccount is uitgeschakeld"
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr "Meerdere gebruikersaccounts delen een gemeenschappelijk e-mailadres."
|
||||
|
||||
#: core/models.py:30
|
||||
msgid "Member"
|
||||
msgstr "Lid"
|
||||
|
||||
#: core/models.py:31
|
||||
msgid "Administrator"
|
||||
msgstr "Beheerder"
|
||||
|
||||
#: core/models.py:32
|
||||
msgid "Owner"
|
||||
msgstr "Eigenaar"
|
||||
|
||||
#: core/models.py:48
|
||||
msgid "Initiated"
|
||||
msgstr "Gestart"
|
||||
|
||||
#: core/models.py:49
|
||||
msgid "Active"
|
||||
msgstr "Actief"
|
||||
|
||||
#: core/models.py:50
|
||||
msgid "Stopped"
|
||||
msgstr "Gestopt"
|
||||
|
||||
#: core/models.py:51
|
||||
msgid "Saved"
|
||||
msgstr "Opgeslagen"
|
||||
|
||||
#: core/models.py:52
|
||||
msgid "Aborted"
|
||||
msgstr "Afgebroken"
|
||||
|
||||
#: core/models.py:53
|
||||
msgid "Failed to Start"
|
||||
msgstr "Starten mislukt"
|
||||
|
||||
#: core/models.py:54
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stoppen mislukt"
|
||||
|
||||
#: core/models.py:55
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificatie geslaagd"
|
||||
|
||||
#: core/models.py:82
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCHERM_OPNAME"
|
||||
|
||||
#: core/models.py:83
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:89
|
||||
msgid "Public Access"
|
||||
msgstr "Openbare toegang"
|
||||
|
||||
#: core/models.py:90
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrouwde toegang"
|
||||
|
||||
#: core/models.py:91
|
||||
msgid "Restricted Access"
|
||||
msgstr "Beperkte toegang"
|
||||
|
||||
#: core/models.py:103
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:104
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: core/models.py:110
|
||||
msgid "created on"
|
||||
msgstr "aangemaakt op"
|
||||
|
||||
#: core/models.py:111
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||
|
||||
#: core/models.py:116
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: core/models.py:117
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||
|
||||
#: core/models.py:137
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||
"_ tekens bevatten."
|
||||
|
||||
#: core/models.py:143
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:145
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
|
||||
|
||||
#: core/models.py:153
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit e-mailadres"
|
||||
|
||||
#: core/models.py:158
|
||||
msgid "admin email address"
|
||||
msgstr "beheerder e-mailadres"
|
||||
|
||||
#: core/models.py:160
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:162
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:168
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:169
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||
|
||||
#: core/models.py:178
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:180
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||
|
||||
#: core/models.py:183
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:185
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: core/models.py:188
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: core/models.py:191
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||
"plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:204
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: core/models.py:205
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/models.py:264
|
||||
msgid "Resource"
|
||||
msgstr "Bron"
|
||||
|
||||
#: core/models.py:265
|
||||
msgid "Resources"
|
||||
msgstr "Bronnen"
|
||||
|
||||
#: core/models.py:319
|
||||
msgid "Resource access"
|
||||
msgstr "Brontoegang"
|
||||
|
||||
#: core/models.py:320
|
||||
msgid "Resource accesses"
|
||||
msgstr "Brontoegangsrechten"
|
||||
|
||||
#: core/models.py:326
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||
|
||||
#: core/models.py:382
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-ruimteconfiguratie"
|
||||
|
||||
#: core/models.py:383
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||
|
||||
#: core/models.py:389 core/models.py:509
|
||||
msgid "Room"
|
||||
msgstr "Ruimte"
|
||||
|
||||
#: core/models.py:390
|
||||
msgid "Rooms"
|
||||
msgstr "Ruimtes"
|
||||
|
||||
#: core/models.py:520
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:522
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||
|
||||
#: core/models.py:530
|
||||
msgid "Recording mode"
|
||||
msgstr "Opnamemodus"
|
||||
|
||||
#: core/models.py:531
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:537
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:538
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:646
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:647
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:653
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:659
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:665
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr "Authenticatie is ingeschakeld maar token is niet geconfigureerd."
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr "Autorisatie-header is vereist"
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr "Ongeldige autorisatie-header."
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr "Ongeldig token"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Je opname is klaar"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logo e-mail"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "NEEM DEEL AAN DE OPROEP"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
msgstr ""
|
||||
"Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je "
|
||||
"browser om deel te nemen aan de oproep."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Tips voor een betere ervaring:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Test je microfoon en camera voordat je deelneemt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Bedankt voor het gebruik van %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "Je opname is klaar!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
" Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s "
|
||||
"is nu klaar om te downloaden. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " De opname verloopt over %(days)s dagen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "Om deze opname permanent te bewaren:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klik op de \"Openen\"-knop hieronder "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Gebruik de \"Download\"-knop in de interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Sla het bestand op naar je gewenste locatie"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr "Openen"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
msgstr ""
|
||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||
"team via %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
msgid "English"
|
||||
msgstr "Engels"
|
||||
|
||||
#: meet/settings.py:163
|
||||
msgid "French"
|
||||
msgstr "Frans"
|
||||
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
@@ -23,6 +23,7 @@ from sentry_sdk.integrations.logging import ignore_logger
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DATA_DIR = os.path.join("/", "data")
|
||||
|
||||
|
||||
def get_release():
|
||||
@@ -69,8 +70,6 @@ class Base(Configuration):
|
||||
|
||||
API_VERSION = "v1.0"
|
||||
|
||||
DATA_DIR = values.Value(os.path.join("/", "data"), environ_name="DATA_DIR")
|
||||
|
||||
# Security
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = values.Value(None)
|
||||
@@ -161,7 +160,6 @@ class Base(Configuration):
|
||||
(
|
||||
("en-us", _("English")),
|
||||
("fr-fr", _("French")),
|
||||
("nl-nl", _("Dutch")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -274,11 +272,6 @@ class Base(Configuration):
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"creation_callback": values.Value(
|
||||
default="600/minute",
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -309,12 +302,6 @@ class Base(Configuration):
|
||||
"silence_livekit_debug_logs": values.BooleanValue(
|
||||
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
|
||||
),
|
||||
"is_silent_login_enabled": values.BooleanValue(
|
||||
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
|
||||
),
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
}
|
||||
|
||||
# Mail
|
||||
@@ -326,11 +313,6 @@ class Base(Configuration):
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
EMAIL_BRAND_NAME = values.Value(None)
|
||||
EMAIL_SUPPORT_EMAIL = values.Value(None)
|
||||
EMAIL_LOGO_IMG = values.Value(None)
|
||||
EMAIL_DOMAIN = values.Value(None)
|
||||
EMAIL_APP_BASE_URL = values.Value(None)
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
|
||||
@@ -449,9 +431,6 @@ class Base(Configuration):
|
||||
),
|
||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||
}
|
||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
|
||||
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
|
||||
)
|
||||
@@ -466,6 +445,9 @@ class Base(Configuration):
|
||||
RECORDING_OUTPUT_FOLDER = values.Value(
|
||||
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
|
||||
)
|
||||
RECORDING_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
RECORDING_WORKER_CLASSES = values.DictValue(
|
||||
{
|
||||
"screen_recording": "core.recording.worker.services.VideoCompositeEgressService",
|
||||
@@ -488,20 +470,12 @@ class Base(Configuration):
|
||||
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
|
||||
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Number of days before recordings expire - must be synced with bucket lifecycle policy
|
||||
# Set to None for no expiration
|
||||
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
|
||||
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_API_TOKEN = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Marketing and communication settings
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||
@@ -554,13 +528,6 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Calendar integrations
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
600, # 10 minutes
|
||||
environ_name="ROOM_CREATION_CALLBACK_CACHE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.20"
|
||||
version = "0.1.16"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.37.24",
|
||||
"boto3==1.37.18",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.4.0",
|
||||
@@ -37,7 +37,7 @@ dependencies = [
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.5",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.8",
|
||||
"django==5.1.7",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
@@ -53,9 +53,10 @@ dependencies = [
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.24.1",
|
||||
"url-normalize==1.4.3",
|
||||
"whitenoise==6.9.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==1.0.2",
|
||||
"livekit-api==0.8.2",
|
||||
"aiohttp==3.11.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.16",
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
@@ -49,7 +49,7 @@
|
||||
"postcss": "8.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.6",
|
||||
"vite": "6.2.3",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
},
|
||||
@@ -9571,9 +9571,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz",
|
||||
"integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==",
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz",
|
||||
"integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.16",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -54,7 +54,7 @@
|
||||
"postcss": "8.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.6",
|
||||
"vite": "6.2.3",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,12 +397,6 @@ const config: Config = {
|
||||
lineHeight: '1.25rem',
|
||||
},
|
||||
},
|
||||
xs: {
|
||||
value: {
|
||||
fontSize: '0.825rem',
|
||||
lineHeight: '1.15rem',
|
||||
},
|
||||
},
|
||||
badge: {
|
||||
value: {
|
||||
fontSize: '0.75rem',
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 457 B |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 27 KiB |
@@ -5,7 +5,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from 'hoofd'
|
||||
import { Switch, Route } from 'wouter'
|
||||
import { Switch, Route, useLocation } from 'wouter'
|
||||
import { I18nProvider } from 'react-aria-components'
|
||||
import { Layout } from './layout/Layout'
|
||||
import { NotFoundScreen } from './components/NotFoundScreen'
|
||||
@@ -13,17 +13,38 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
import { SdkCreateButton } from './features/sdk/routes/CreateButton'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
const isSDKContext = useIsSdkContext()
|
||||
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}>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<AppInitialization />
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
@@ -34,10 +55,7 @@ function App() {
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-left" />
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export const mediaUrl = (path: string) => {
|
||||
const origin =
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '')
|
||||
|
||||
// Remove leading/trailing slashes from origin/path if it exists
|
||||
const sanitizedOrigin = origin.replace(/\/$/, '')
|
||||
const sanitizedPath = path.replace(/^\//, '')
|
||||
|
||||
return `${sanitizedOrigin}/media/${sanitizedPath}`
|
||||
}
|
||||
@@ -4,5 +4,4 @@ export const keys = {
|
||||
config: 'config',
|
||||
requestEntry: 'requestEntry',
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
roomCreationCallback: 'roomCreationCallback',
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
|
||||
export interface ApiConfig {
|
||||
analytics?: {
|
||||
@@ -11,15 +11,10 @@ export interface ApiConfig {
|
||||
support?: {
|
||||
id: string
|
||||
}
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
recording?: {
|
||||
is_enabled?: boolean
|
||||
available_modes?: RecordingMode[]
|
||||
expiration_days?: number
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 704 KiB |
@@ -14,7 +14,6 @@ export const AppInitialization = () => {
|
||||
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
|
||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||
|
||||
return null
|
||||
|
||||
@@ -2,14 +2,10 @@ import { css } from '@/styled-system/css'
|
||||
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text, A } from '@/primitives'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { GRIST_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackBanner = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.feedback?.url) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -39,7 +35,7 @@ export const FeedbackBanner = () => {
|
||||
gap: 0.25,
|
||||
})}
|
||||
>
|
||||
<A href={data?.feedback?.url} target="_blank" size="sm">
|
||||
<A href={GRIST_FORM} target="_blank" size="sm">
|
||||
{t('feedback.cta')}
|
||||
</A>
|
||||
<RiExternalLinkLine size={16} aria-hidden="true" />
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export enum FeatureFlags {
|
||||
Transcript = 'transcription-summary',
|
||||
ScreenRecording = 'screen-recording',
|
||||
faceLandmarks = 'face-landmarks',
|
||||
}
|
||||
@@ -17,19 +17,18 @@ export const terminateAnalyticsSession = () => {
|
||||
export type useAnalyticsProps = {
|
||||
id?: string
|
||||
host?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
||||
export const useAnalytics = ({ id, host }: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
useEffect(() => {
|
||||
if (!id || !host || isDisabled) return
|
||||
if (!id || !host) return
|
||||
if (posthog.__loaded) return
|
||||
posthog.init(id, {
|
||||
api_host: host,
|
||||
person_profiles: 'always',
|
||||
})
|
||||
}, [id, host, isDisabled])
|
||||
}, [id, host])
|
||||
|
||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { BackendLanguage } from '@/utils/languages'
|
||||
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
email: string
|
||||
full_name: string
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const fetchUser = (
|
||||
// make sure to not resolve the promise while trying to silent login
|
||||
// so that consumers of fetchUser don't think the work already ended
|
||||
if (opts.attemptSilent && canAttemptSilentLogin()) {
|
||||
attemptSilentLogin(30)
|
||||
attemptSilentLogin(300)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { type ApiUser } from './ApiUser'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export type ApiUserPreferences = Pick<ApiUser, 'id' | 'timezone' | 'language'>
|
||||
|
||||
export const updateUserPreferences = async ({
|
||||
user,
|
||||
}: {
|
||||
user: ApiUserPreferences
|
||||
}): Promise<ApiUser> => {
|
||||
return await fetchApi(`/users/${user.id}/`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
|
||||
})
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { updateUserPreferences } from './updateUserPreferences'
|
||||
import { convertToBackendLanguage } from '@/utils/languages'
|
||||
import { useUser } from './useUser'
|
||||
|
||||
/**
|
||||
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
|
||||
* Automatically updates backend when browser settings change for logged-in users.
|
||||
*/
|
||||
export const useSyncUserPreferencesWithBackend = () => {
|
||||
const { i18n } = useTranslation()
|
||||
const { user, isLoggedIn } = useUser()
|
||||
|
||||
const { mutateAsync } = useMutation({
|
||||
mutationFn: updateUserPreferences,
|
||||
onSuccess: (updatedUser) => {
|
||||
queryClient.setQueryData([keys.user], updatedUser)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !isLoggedIn) return
|
||||
|
||||
const syncBrowserPreferencesToBackend = async () => {
|
||||
const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const currentLanguage = convertToBackendLanguage(i18n.language)
|
||||
if (
|
||||
currentLanguage !== user.language ||
|
||||
currentTimezone !== user.timezone
|
||||
) {
|
||||
await mutateAsync({
|
||||
user: {
|
||||
id: user.id,
|
||||
timezone: currentTimezone,
|
||||
language: currentLanguage,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
syncBrowserPreferencesToBackend()
|
||||
}, [i18n.language, isLoggedIn, user, mutateAsync])
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { fetchUser } from './fetchUser'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
startAnalyticsSession,
|
||||
terminateAnalyticsSession,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
terminateSupportSession,
|
||||
} from '@/features/support/hooks/useSupport'
|
||||
import { logoutUrl } from '../utils/logoutUrl'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
/**
|
||||
* returns info about currently logged-in user
|
||||
@@ -24,25 +23,11 @@ export const useUser = (
|
||||
fetchUserOptions?: Parameters<typeof fetchUser>[0]
|
||||
} = {}
|
||||
) => {
|
||||
const { data, isLoading: isConfigLoading } = useConfig()
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (isConfigLoading) return
|
||||
if (data?.is_silent_login_enabled !== true) {
|
||||
return {
|
||||
...opts,
|
||||
attemptSilent: false,
|
||||
}
|
||||
}
|
||||
return opts.fetchUserOptions
|
||||
}, [data, opts, isConfigLoading])
|
||||
|
||||
const query = useQuery({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
queryKey: [keys.user],
|
||||
queryFn: () => fetchUser(options),
|
||||
queryFn: () => fetchUser(opts.fetchUserOptions),
|
||||
staleTime: Infinity,
|
||||
enabled: !isConfigLoading,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,7 +51,6 @@ export const useUser = (
|
||||
refetch: query.refetch,
|
||||
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
|
||||
isLoggedIn,
|
||||
isLoading: query.isLoading,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { useUser } from './api/useUser'
|
||||
export { useSyncUserPreferencesWithBackend } from './api/useSyncUserPreferencesWithBackend'
|
||||
export { authUrl } from './utils/authUrl'
|
||||
export { UserAware } from './components/UserAware'
|
||||
|
||||
@@ -8,7 +8,10 @@ import { Button, LinkButton } from '@/primitives'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BETA_USERS_FORM_URL } from '@/utils/constants'
|
||||
|
||||
// todo - extract in a proper env variable
|
||||
const BETA_USERS_FORM_URL =
|
||||
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'
|
||||
|
||||
const Heading = styled('h2', {
|
||||
base: {
|
||||
@@ -204,7 +207,6 @@ export const IntroSlider = () => {
|
||||
{slide.isAvailableInBeta && (
|
||||
<LinkButton
|
||||
href={BETA_USERS_FORM_URL}
|
||||
target="_blank"
|
||||
tooltip={t('beta.tooltip')}
|
||||
variant={'primary'}
|
||||
size={'sm'}
|
||||
|
||||
@@ -13,11 +13,12 @@ import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { SdkReverseClient } from '@/features/sdk/SdkReverseClient'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -155,6 +156,18 @@ export const Home = () => {
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
||||
|
||||
const { user } = useUser()
|
||||
|
||||
/**
|
||||
* Used for SDK popup to close automatically.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
SdkReverseClient.broadcastAuthentication()
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
|
||||
@@ -88,18 +88,6 @@ export const MainNotificationToast = () => {
|
||||
if (notification.data?.emoji)
|
||||
handleEmoji(notification.data.emoji, participant)
|
||||
break
|
||||
case NotificationType.TranscriptionStarted:
|
||||
case NotificationType.TranscriptionStopped:
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: notification.type,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
)
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,8 +6,4 @@ export enum NotificationType {
|
||||
LowerHand = 'lowerHand',
|
||||
ReactionReceived = 'reactionReceived',
|
||||
ParticipantWaiting = 'participantWaiting',
|
||||
TranscriptionStarted = 'transcriptionStarted',
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function Toast({ state, ...props }: ToastProps) {
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<StyledToast>
|
||||
<div {...contentProps}>{props.toast.content?.message}</div>
|
||||
<div {...contentProps}>{props.toast.content?.message} machine a</div>
|
||||
<Button square size="sm" invisible {...closeButtonProps}>
|
||||
<RiCloseLine color="white" />
|
||||
</Button>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
|
||||
export function ToastAnyRecording({ state, ...props }: ToastProps) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const participant = props.toast.content.participant
|
||||
const type = props.toast.content.type
|
||||
|
||||
const key = useMemo(() => {
|
||||
switch (type) {
|
||||
case NotificationType.TranscriptionStarted:
|
||||
return 'transcript.started'
|
||||
case NotificationType.TranscriptionStopped:
|
||||
return 'transcript.stopped'
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
return 'screenRecording.started'
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return 'screenRecording.stopped'
|
||||
default:
|
||||
return
|
||||
}
|
||||
}, [type])
|
||||
|
||||
if (!key) return
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t(key, {
|
||||
name: participant.name,
|
||||
})}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { ToastRaised } from './ToastRaised'
|
||||
import { ToastMuted } from './ToastMuted'
|
||||
import { ToastMessageReceived } from './ToastMessageReceived'
|
||||
import { ToastLowerHand } from './ToastLowerHand'
|
||||
import { ToastAnyRecording } from './ToastAnyRecording'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -37,12 +36,6 @@ const renderToast = (
|
||||
case NotificationType.LowerHand:
|
||||
return <ToastLowerHand key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.TranscriptionStarted:
|
||||
case NotificationType.TranscriptionStopped:
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
|
||||
@@ -10,14 +10,10 @@ 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'
|
||||
import { useNotificationSound } from '../hooks/useSoundNotification'
|
||||
import { NotificationType } from '@/features/notifications'
|
||||
|
||||
export const NOTIFICATION_DISPLAY_DURATION = 10000
|
||||
|
||||
export const WaitingParticipantNotification = () => {
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
|
||||
const { t } = useTranslation('notifications', {
|
||||
keyPrefix: 'waitingParticipants',
|
||||
})
|
||||
@@ -41,9 +37,6 @@ export const WaitingParticipantNotification = () => {
|
||||
!isParticipantsOpen
|
||||
) {
|
||||
setShowQuickActionsMessage(true)
|
||||
|
||||
triggerNotificationSound(NotificationType.ParticipantJoined)
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
@@ -55,12 +48,7 @@ export const WaitingParticipantNotification = () => {
|
||||
// Hide notification when the participant count changes
|
||||
setShowQuickActionsMessage(false)
|
||||
}
|
||||
}, [
|
||||
waitingParticipants,
|
||||
prevWaitingParticipant,
|
||||
isParticipantsOpen,
|
||||
triggerNotificationSound,
|
||||
])
|
||||
}, [waitingParticipants, prevWaitingParticipant, isParticipantsOpen])
|
||||
|
||||
useEffect(() => {
|
||||
// This cleanup function will only run when the component unmounts
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
import { NotificationPayload } from '../NotificationPayload'
|
||||
|
||||
export const useNotifyParticipants = () => {
|
||||
const room = useRoomContext()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const notifyParticipants = async <T extends Record<string, any>>(options: {
|
||||
type: NotificationType
|
||||
destinationIdentities?: string[]
|
||||
additionalData?: T
|
||||
reliable?: boolean
|
||||
}): Promise<void> => {
|
||||
const {
|
||||
type,
|
||||
destinationIdentities,
|
||||
additionalData = {} as T,
|
||||
reliable = true,
|
||||
} = options
|
||||
|
||||
const payload: NotificationPayload & T = {
|
||||
type,
|
||||
...additionalData,
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(JSON.stringify(payload))
|
||||
|
||||
await room.localParticipant.publishData(data, {
|
||||
reliable,
|
||||
destinationIdentities,
|
||||
})
|
||||
}
|
||||
|
||||
return { notifyParticipants }
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { useNotifyParticipants } from './hooks/useNotifyParticipants'
|
||||
export { NotificationType } from './NotificationType'
|
||||
@@ -1,18 +0,0 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { RecordingMode, RecordingStatus } from '@/features/recording'
|
||||
|
||||
export type RecordingApi = {
|
||||
id: string
|
||||
room: Pick<ApiRoom, 'id' | 'name' | 'slug' | 'access_level'>
|
||||
created_at: string
|
||||
key: string
|
||||
mode: RecordingMode
|
||||
status: RecordingStatus
|
||||
is_expired: boolean
|
||||
expired_at: string
|
||||
}
|
||||
|
||||
export const fetchRecording = ({ recordingId }: { recordingId?: string }) => {
|
||||
return fetchApi<RecordingApi>(`/recordings/${recordingId}/`)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { RiRecordCircleLine } from '@remixicon/react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
useIsRecordingActive,
|
||||
} from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'recordingStateToast',
|
||||
})
|
||||
const room = useRoomContext()
|
||||
|
||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
|
||||
const isScreenRecordingActive = useIsRecordingActive(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (room.isRecording && recordingSnap.status == RecordingStatus.STOPPED) {
|
||||
recordingStore.status = RecordingStatus.ANY_STARTED
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room.isRecording])
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant
|
||||
) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
|
||||
if (!participant || !notification) return
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.TranscriptionStarted:
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
break
|
||||
case NotificationType.TranscriptionStopped:
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecordingStatusChanged = (status: boolean) => {
|
||||
if (!status) {
|
||||
recordingStore.status = RecordingStatus.STOPPED
|
||||
} else if (recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING) {
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTED
|
||||
} else if (
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
) {
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTED
|
||||
} else {
|
||||
recordingStore.status = RecordingStatus.ANY_STARTED
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room, recordingSnap])
|
||||
|
||||
const key = useMemo(() => {
|
||||
switch (recordingSnap.status) {
|
||||
case RecordingStatus.TRANSCRIPT_STARTED:
|
||||
return 'transcript.started'
|
||||
case RecordingStatus.TRANSCRIPT_STOPPING:
|
||||
return 'transcript.stopping'
|
||||
case RecordingStatus.TRANSCRIPT_STARTING:
|
||||
return 'transcript.starting'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTED:
|
||||
return 'screenRecording.started'
|
||||
case RecordingStatus.SCREEN_RECORDING_STOPPING:
|
||||
return 'screenRecording.stopping'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTING:
|
||||
return 'screenRecording.starting'
|
||||
case RecordingStatus.ANY_STARTED:
|
||||
return 'any.started'
|
||||
default:
|
||||
return
|
||||
}
|
||||
}, [recordingSnap])
|
||||
|
||||
if (!key) return
|
||||
|
||||
const isStarted = key?.includes('started')
|
||||
|
||||
const hasScreenRecordingAccessAndActive =
|
||||
isScreenRecordingActive && hasScreenRecordingAccess
|
||||
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.75rem 0.75rem',
|
||||
backgroundColor: 'danger.700',
|
||||
borderColor: 'white',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{isStarted ? (
|
||||
<RiRecordCircleLine
|
||||
size={20}
|
||||
className={css({
|
||||
animation: 'pulse_background 1s infinite',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Spinner size={20} variant="dark" />
|
||||
)}
|
||||
|
||||
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
fontWeight: '500 !important',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</Text>
|
||||
)}
|
||||
{hasScreenRecordingAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openScreenRecording}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
{hasTranscriptAccessAndActive && (
|
||||
<RACButton
|
||||
onPress={openTranscript}
|
||||
className={css({
|
||||
textStyle: 'sm !important',
|
||||
fontWeight: '500 !important',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</RACButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { A, Button, Div, H, Text } from '@/primitives'
|
||||
|
||||
import fourthSlide from '@/assets/intro-slider/4_record.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
} from '@/features/recording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
|
||||
import { useIsRecordingTransitioning } from '@/features/recording'
|
||||
|
||||
import {
|
||||
useNotifyParticipants,
|
||||
NotificationType,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
|
||||
export const ScreenRecordingSidePanel = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
isAnotherModeStarted:
|
||||
recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
|
||||
isStarting:
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING,
|
||||
isStarted:
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
|
||||
isStopping:
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STOPPING,
|
||||
}
|
||||
}, [recordingSnap])
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const handleScreenRecording = async () => {
|
||||
if (!roomId) {
|
||||
console.warn('No room ID found')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStopped,
|
||||
})
|
||||
} else {
|
||||
await startRecordingRoom({
|
||||
id: roomId,
|
||||
mode: RecordingMode.ScreenRecording,
|
||||
})
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStarted,
|
||||
})
|
||||
posthog.capture('screen-recording-started', {})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
|
||||
[isLoading, isRecordingTransitioning, statuses]
|
||||
)
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img
|
||||
src={fourthSlide}
|
||||
alt={''}
|
||||
className={css({
|
||||
minHeight: '309px',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
/>
|
||||
|
||||
{statuses.isStarted ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stop.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="stop-screen-recording"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStopping ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stopping.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
maxWidth: '90%',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('stopping.body')}
|
||||
</Text>
|
||||
<Spinner />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('start.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
maxWidth: '90%',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('start.body')} <br />{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_RECORDING} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="start-screen-recording"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{statuses.isStarting ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('start.button')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import { A, Button, Div, H, LinkButton, Text } from '@/primitives'
|
||||
|
||||
import thirdSlide from '@/assets/intro-slider/3_resume.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
useIsRecordingTransitioning,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
} from '../index'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import {
|
||||
BETA_USERS_FORM_URL,
|
||||
CRISP_HELP_ARTICLE_TRANSCRIPT,
|
||||
} from '@/utils/constants'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import {
|
||||
NotificationType,
|
||||
useNotifyParticipants,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
|
||||
export const TranscriptSidePanel = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
isAnotherModeStarted:
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
|
||||
isStarting: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING,
|
||||
isStarted: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
|
||||
isStopping: recordingSnap.status == RecordingStatus.TRANSCRIPT_STOPPING,
|
||||
}
|
||||
}, [recordingSnap])
|
||||
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const handleTranscript = async () => {
|
||||
if (!roomId) {
|
||||
console.warn('No room ID found')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionStopped,
|
||||
})
|
||||
} else {
|
||||
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionStarted,
|
||||
})
|
||||
posthog.capture('transcript-started', {})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
|
||||
[isLoading, isRecordingTransitioning, statuses]
|
||||
)
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img
|
||||
src={thirdSlide}
|
||||
alt={''}
|
||||
className={css({
|
||||
minHeight: '309px',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
/>
|
||||
{!hasTranscriptAccess ? (
|
||||
<>
|
||||
<Text>{t('beta.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('beta.body')}{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
<LinkButton
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
href={BETA_USERS_FORM_URL}
|
||||
target="_blank"
|
||||
>
|
||||
{t('beta.button')}
|
||||
</LinkButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStarted ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stop.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="stop-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStopping ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stopping.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
maxWidth: '90%',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('stopping.body')}
|
||||
</Text>
|
||||
<Spinner />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('start.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
maxWidth: '90%',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('start.body')} <br />{' '}
|
||||
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="start-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{statuses.isStarting ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('start.button')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { RecordingMode } from '../types'
|
||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasRecordingAccess = (
|
||||
mode: RecordingMode,
|
||||
featureFlag: FeatureFlags
|
||||
) => {
|
||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isAdminOrOwner &&
|
||||
isRecordingModeEnabled
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
|
||||
export const useIsRecordingActive = (mode: RecordingMode) => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
switch (mode) {
|
||||
case RecordingMode.Transcript:
|
||||
return [
|
||||
RecordingStatus.TRANSCRIPT_STARTED,
|
||||
RecordingStatus.TRANSCRIPT_STARTING,
|
||||
RecordingStatus.TRANSCRIPT_STOPPING,
|
||||
].includes(recordingSnap.status)
|
||||
case RecordingMode.ScreenRecording:
|
||||
return [
|
||||
RecordingStatus.SCREEN_RECORDING_STARTED,
|
||||
RecordingStatus.SCREEN_RECORDING_STARTING,
|
||||
RecordingStatus.SCREEN_RECORDING_STOPPING,
|
||||
].includes(recordingSnap.status)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { RecordingMode } from '../types'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const useIsRecordingModeEnabled = (mode: RecordingMode) => {
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
data?.recording?.is_enabled &&
|
||||
data?.recording?.available_modes?.includes(mode)
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const useIsRecordingTransitioning = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const transitionalStates = [
|
||||
RecordingStatus.TRANSCRIPT_STARTING,
|
||||
RecordingStatus.TRANSCRIPT_STOPPING,
|
||||
RecordingStatus.SCREEN_RECORDING_STARTING,
|
||||
RecordingStatus.SCREEN_RECORDING_STOPPING,
|
||||
]
|
||||
|
||||
return transitionalStates.includes(recordingSnap.status)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// hooks
|
||||
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
||||
export { useIsRecordingTransitioning } from './hooks/useIsRecordingTransitioning'
|
||||
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
||||
export { useIsRecordingActive } from './hooks/useIsRecordingActive'
|
||||
|
||||
// api
|
||||
export { useStartRecording } from './api/startRecording'
|
||||
export { useStopRecording } from './api/stopRecording'
|
||||
export { RecordingMode, RecordingStatus } from './types'
|
||||
|
||||
// components
|
||||
export { RecordingStateToast } from './components/RecordingStateToast'
|
||||
export { TranscriptSidePanel } from './components/TranscriptSidePanel'
|
||||
export { ScreenRecordingSidePanel } from './components/ScreenRecordingSidePanel'
|
||||
|
||||
// routes
|
||||
export { RecordingDownload as RecordingDownloadRoute } from './routes/RecordingDownload'
|
||||
@@ -1,112 +0,0 @@
|
||||
import { useParams } from 'wouter'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Center, VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import fourthSlide from '@/assets/intro-slider/4_record.png'
|
||||
import { mediaUrl } from '@/api/mediaUrl'
|
||||
import { UserAware, useUser } from '@/features/auth'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { H, LinkButton, Text } from '@/primitives'
|
||||
import { formatDate } from '@/utils/formatDate'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
import { fetchRecording } from '../api/fetchRecording'
|
||||
import { RecordingStatus } from '@/features/recording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const RecordingDownload = () => {
|
||||
const { t } = useTranslation('recording')
|
||||
const { data: configData } = useConfig()
|
||||
const { recordingId } = useParams()
|
||||
const { isLoggedIn, isLoading: isAuthLoading } = useUser()
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['recording', recordingId],
|
||||
queryFn: () => fetchRecording({ recordingId }),
|
||||
retry: false,
|
||||
enabled: !!recordingId,
|
||||
})
|
||||
|
||||
if (isLoading || !data || isAuthLoading) {
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={t('authentication.title')}
|
||||
body={t('authentication.body')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <ErrorScreen title={t('error.title')} body={t('error.body')} />
|
||||
}
|
||||
|
||||
if (
|
||||
data.status !== RecordingStatus.Saved &&
|
||||
data.status !== RecordingStatus.NotificationSucceed
|
||||
) {
|
||||
return <ErrorScreen title={t('unsaved.title')} body={t('unsaved.body')} />
|
||||
}
|
||||
|
||||
if (data.is_expired) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={t('expired.title')}
|
||||
body={t('expired.body', {
|
||||
date: formatDate(data?.expired_at, 'YYYY-MM-DD HH:mm'),
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Center>
|
||||
<VStack>
|
||||
<img
|
||||
src={fourthSlide}
|
||||
alt={''}
|
||||
className={css({
|
||||
maxHeight: '309px',
|
||||
})}
|
||||
/>
|
||||
<H lvl={1} centered>
|
||||
{t('success.title')}
|
||||
</H>
|
||||
<Text centered margin="md" wrap={'balance'}>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('success.body', {
|
||||
room: data.room.name,
|
||||
created_at: formatDate(data.created_at, 'YYYY-MM-DD HH:mm'),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{configData?.recording?.expiration_days && (
|
||||
<>
|
||||
{' '}
|
||||
{t('success.expiration', {
|
||||
expiration_days: configData?.recording?.expiration_days,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Text>
|
||||
<LinkButton
|
||||
href={mediaUrl(data.key)}
|
||||
download={`${data.room.name}-${formatDate(data.created_at)}`}
|
||||
>
|
||||
{t('success.button')}
|
||||
</LinkButton>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export enum RecordingMode {
|
||||
Transcript = 'transcript',
|
||||
ScreenRecording = 'screen_recording',
|
||||
}
|
||||
|
||||
export enum RecordingStatus {
|
||||
Initiated = 'initiated',
|
||||
Active = 'active',
|
||||
Stopped = 'stopped',
|
||||
Saved = 'saved',
|
||||
Aborted = 'aborted',
|
||||
FailedToStart = 'failedToStart',
|
||||
FailedToStop = 'failedToStop',
|
||||
NotificationSucceed = 'notification_succeeded',
|
||||
}
|
||||
@@ -5,20 +5,17 @@ import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface CreateRoomParams {
|
||||
slug: string
|
||||
callbackId?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
const createRoom = ({
|
||||
slug,
|
||||
callbackId,
|
||||
username = '',
|
||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
callback_id: callbackId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { RecordingMode } from '../types'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export enum RecordingMode {
|
||||
Transcript = 'transcript',
|
||||
ScreenRecording = 'screen_recording',
|
||||
}
|
||||
|
||||
export interface StartRecordingParams {
|
||||
id: string
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface StopRecordingParams {
|
||||
id: string
|
||||
@@ -353,6 +353,7 @@ export const Join = ({
|
||||
type="text"
|
||||
onChange={setUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
defaultValue={initialUserChoices?.username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
|
||||
@@ -6,7 +6,6 @@ import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { PostHog } from 'posthog-js'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
|
||||
const Card = styled('div', {
|
||||
base: {
|
||||
@@ -300,7 +299,6 @@ const AuthenticationMessage = ({
|
||||
}
|
||||
|
||||
export const Rating = () => {
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const posthog = usePostHog()
|
||||
|
||||
const isUserAnonymous = useMemo(() => {
|
||||
@@ -309,8 +307,6 @@ export const Rating = () => {
|
||||
|
||||
const [step, setStep] = useState(0)
|
||||
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
if (step == 0) {
|
||||
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
|
||||
}
|
||||
|
||||
@@ -3,15 +3,25 @@ import Source = Track.Source
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import {
|
||||
useNotifyParticipants,
|
||||
NotificationType,
|
||||
} from '@/features/notifications'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const data = useRoomData()
|
||||
const room = useRoomContext()
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
const notifyParticipant = async (participant: Participant) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
type: NotificationType.ParticipantMuted,
|
||||
}
|
||||
const data = encoder.encode(JSON.stringify(payload))
|
||||
await room.localParticipant.publishData(data, {
|
||||
reliable: true,
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
}
|
||||
|
||||
const muteParticipant = async (participant: Participant) => {
|
||||
if (!data || !data?.livekit) {
|
||||
@@ -43,10 +53,7 @@ export const useMuteParticipant = () => {
|
||||
}
|
||||
)
|
||||
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ParticipantMuted,
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
await notifyParticipant(participant)
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||
import { Button, Div, Text } from '@/primitives'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
|
||||
export const Info = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
const data = useRoomData()
|
||||
const roomUrl = getRouteUrl('room', data?.slug)
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
>
|
||||
<VStack alignItems="start">
|
||||
<Text
|
||||
as="h3"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{t('roomInformation.title')}
|
||||
</Text>
|
||||
<Text as="p" variant="xsNote" wrap="pretty">
|
||||
{roomUrl}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||
aria-label={t('roomInformation.button.ariaLabel')}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
setIsCopied(true)
|
||||
}}
|
||||
data-attr="copy-info-sidepannel"
|
||||
style={{
|
||||
marginLeft: '-8px',
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={24} style={{ marginRight: '6px' }} />
|
||||
{t('roomInformation.button.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '6px' }} />
|
||||
{t('roomInformation.button.copy')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParticipantInfo } from '@livekit/components-react'
|
||||
import { Participant } from 'livekit-client'
|
||||
|
||||
export const ParticipantName = ({
|
||||
participant,
|
||||
isScreenShare = false,
|
||||
}: {
|
||||
participant: Participant
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
|
||||
|
||||
const { identity, name } = useParticipantInfo({ participant })
|
||||
const displayedName = name != '' ? name : identity
|
||||
|
||||
if (isScreenShare) {
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
marginLeft: '0.4rem',
|
||||
}}
|
||||
>
|
||||
{t('screenShare', { name: displayedName })}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
}}
|
||||
>
|
||||
{displayedName}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AudioTrack,
|
||||
ConnectionQualityIndicator,
|
||||
LockLockedIcon,
|
||||
ParticipantName,
|
||||
ParticipantTileProps,
|
||||
ScreenShareIcon,
|
||||
useEnsureTrackRef,
|
||||
@@ -28,7 +29,6 @@ import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -97,8 +97,6 @@ export const ParticipantTile: (
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
@@ -131,50 +129,45 @@ export const ParticipantTile: (
|
||||
{!disableMetadata && (
|
||||
<div className="lk-participant-metadata">
|
||||
<HStack gap={0.25}>
|
||||
{!isScreenShare && (
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
)}
|
||||
<MutedMicIndicator
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
<div
|
||||
className="lk-participant-metadata-item"
|
||||
style={{
|
||||
padding: '0.1rem 0.25rem',
|
||||
backgroundColor:
|
||||
isHandRaised && !isScreenShare ? 'white' : undefined,
|
||||
color:
|
||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||
minHeight: '24px',
|
||||
backgroundColor: isHandRaised ? 'white' : undefined,
|
||||
color: isHandRaised ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
{trackReference.source === Track.Source.Camera ? (
|
||||
<>
|
||||
{isHandRaised && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginInlineEnd: '.25rem', // fixme - match TrackMutedIndicator styling
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && (
|
||||
<LockLockedIcon
|
||||
style={{ marginRight: '0.25rem' }}
|
||||
/>
|
||||
)}
|
||||
<ParticipantName />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
|
||||
<ParticipantName>'s screen</ParticipantName>
|
||||
</>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
style={{
|
||||
maxWidth: '20px',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiRecordCircleLine } from '@remixicon/react'
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'recording' })
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
if (!room?.isRecording) return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
position: 'fixed',
|
||||
top: '10px',
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.25rem 0.35rem',
|
||||
backgroundColor: 'primaryDark.200',
|
||||
borderColor: 'primaryDark.400',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<RiRecordCircleLine
|
||||
size={20}
|
||||
className={css({
|
||||
color: 'white',
|
||||
backgroundColor: 'danger.700',
|
||||
padding: '3px',
|
||||
borderRadius: '3px',
|
||||
})}
|
||||
/>
|
||||
<Text variant={'sm'}>{t('label')}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,16 +3,15 @@ import { css } from '@/styled-system/css'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { text } from '@/primitives/Text'
|
||||
import { Button, Div } from '@/primitives'
|
||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ReactNode } from 'react'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
import { Transcript } from './Transcript'
|
||||
import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -20,8 +19,6 @@ type StyledSidePanelProps = {
|
||||
onClose: () => void
|
||||
isClosed: boolean
|
||||
closeButtonTooltip: string
|
||||
isSubmenu: boolean
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
@@ -30,8 +27,6 @@ const StyledSidePanel = ({
|
||||
onClose,
|
||||
isClosed,
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
}: StyledSidePanelProps) => (
|
||||
<div
|
||||
className={css({
|
||||
@@ -66,22 +61,9 @@ const StyledSidePanel = ({
|
||||
style={{
|
||||
paddingLeft: '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
display: isClosed ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size={'sm'}
|
||||
square
|
||||
className={css({ marginRight: '0.5rem' })}
|
||||
onPress={onBack}
|
||||
>
|
||||
<RiArrowLeftLine size={20} />
|
||||
</Button>
|
||||
)}
|
||||
{title}
|
||||
</Heading>
|
||||
<Div
|
||||
@@ -110,10 +92,9 @@ const StyledSidePanel = ({
|
||||
type PanelProps = {
|
||||
isOpen: boolean
|
||||
children: React.ReactNode
|
||||
keepAlive?: boolean
|
||||
}
|
||||
|
||||
const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
const Panel = ({ isOpen, children }: PanelProps) => (
|
||||
<div
|
||||
style={{
|
||||
display: isOpen ? 'inherit' : 'none',
|
||||
@@ -122,7 +103,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{keepAlive || isOpen ? children : null}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -133,27 +114,19 @@ export const SidePanel = () => {
|
||||
isEffectsOpen,
|
||||
isChatOpen,
|
||||
isSidePanelOpen,
|
||||
isToolsOpen,
|
||||
isTranscriptOpen,
|
||||
isAdminOpen,
|
||||
isInfoOpen,
|
||||
isSubPanelOpen,
|
||||
activeSubPanelId,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}}
|
||||
title={t(`heading.${activePanelId}`)}
|
||||
onClose={() => (layoutStore.activePanelId = null)}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||
content: t(`content.${activePanelId}`),
|
||||
})}
|
||||
isClosed={!isSidePanelOpen}
|
||||
isSubmenu={isSubPanelOpen}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
<ParticipantsList />
|
||||
@@ -161,18 +134,15 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isEffectsOpen}>
|
||||
<Effects />
|
||||
</Panel>
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isToolsOpen}>
|
||||
<Tools />
|
||||
<Panel isOpen={isTranscriptOpen}>
|
||||
<Transcript />
|
||||
</Panel>
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
<Admin />
|
||||
</Panel>
|
||||
<Panel isOpen={isInfoOpen}>
|
||||
<Info />
|
||||
</Panel>
|
||||
</StyledSidePanel>
|
||||
)
|
||||
}
|
||||
|
||||