Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6cb735a09 | |||
| 461ab70918 | |||
| bc6bf73e71 | |||
| 566b085ede | |||
| 84d51bc86c | |||
| 66eab072ee | |||
| f390ad46d5 | |||
| 6bdf1d173b | |||
| 02cc74e06f | |||
| 784d97efd6 | |||
| 1f44edcdf3 | |||
| 92e87fcd32 | |||
| 5138f66262 | |||
| 9a21404d23 | |||
| 85784419a3 | |||
| 3431df05af | |||
| 4e9dd87a7a | |||
| e4c27aa840 | |||
| 280cd01df5 | |||
| 1a552282a5 |
@@ -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:
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
-35
@@ -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}
|
||||
|
||||
@@ -287,7 +287,6 @@ These are the environmental options available on meet backend.
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
|
||||
| OIDC_OP_USER_ENDPOINT_FORMAT | oidc endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
|
||||
| OIDC_RP_SCOPES | oidc scopes | openid email |
|
||||
@@ -302,7 +301,6 @@ These are the environmental options available on meet backend.
|
||||
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
|
||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | required claims from OIDC token | [] |
|
||||
| LIVEKIT_API_KEY | livekit api key | |
|
||||
| LIVEKIT_API_SECRET | livekit api secret | |
|
||||
| LIVEKIT_API_URL | livekit api url | |
|
||||
@@ -310,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"]
|
||||
|
||||
|
||||
@@ -120,7 +117,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
role = instance.get_role(request.user)
|
||||
is_admin = models.RoleChoices.check_administrator_role(role)
|
||||
|
||||
if is_admin:
|
||||
if role is not None:
|
||||
access_serializer = NestedResourceAccessSerializer(
|
||||
instance.accesses.select_related("resource", "user").all(),
|
||||
context=self.context,
|
||||
@@ -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", "mode"]
|
||||
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,11 +204,11 @@ 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")
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class CreationCallbackSerializer(serializers.Serializer):
|
||||
@@ -231,22 +218,8 @@ class CreationCallbackSerializer(serializers.Serializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("CreationCallbackSerializer 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("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")
|
||||
|
||||
@@ -4,6 +4,7 @@ import uuid
|
||||
from logging import getLogger
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
@@ -21,8 +22,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, enums
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
@@ -41,7 +41,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,
|
||||
@@ -366,7 +365,7 @@ class RoomViewSet(
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
methods=["POST"],
|
||||
url_path="request-entry",
|
||||
permission_classes=[],
|
||||
throttle_classes=[RequestEntryAnonRateThrottle],
|
||||
@@ -449,7 +448,7 @@ class RoomViewSet(
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
methods=["POST"],
|
||||
url_path="webhooks-livekit",
|
||||
permission_classes=[],
|
||||
)
|
||||
@@ -475,7 +474,7 @@ class RoomViewSet(
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
methods=["POST"],
|
||||
url_path="creation-callback",
|
||||
permission_classes=[],
|
||||
throttle_classes=[CreationCallbackAnonRateThrottle],
|
||||
@@ -498,62 +497,22 @@ class RoomViewSet(
|
||||
{"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.
|
||||
"""
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
|
||||
room = self.get_object()
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list rooms access"""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
return super().get_permissions()
|
||||
|
||||
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 ResourceAccessViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API endpoints to access and perform actions on resource accesses.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.ResourceAccessPermission]
|
||||
queryset = models.ResourceAccess.objects.all()
|
||||
serializer_class = serializers.ResourceAccessSerializer
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Restrict access to resources the user either has explicit
|
||||
# permissions for or administrative privileges over.
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(
|
||||
@@ -563,10 +522,27 @@ class ResourceAccessViewSet(
|
||||
models.RoleChoices.OWNER,
|
||||
],
|
||||
).distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class ResourceAccessViewSet(
|
||||
ResourceAccessListModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API endpoints to access and perform actions on resource accesses.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.ResourceAccessPermission]
|
||||
queryset = models.ResourceAccess.objects.all()
|
||||
serializer_class = serializers.ResourceAccessSerializer
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
@@ -618,7 +594,7 @@ class RecordingViewSet(
|
||||
)
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(id=recording_id)
|
||||
recording = models.Recording.objects.select_related('room').get(id=recording_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||
|
||||
@@ -645,14 +621,17 @@ class RecordingViewSet(
|
||||
{"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
|
||||
@@ -686,6 +665,7 @@ class RecordingViewSet(
|
||||
"""
|
||||
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
|
||||
@@ -699,30 +679,24 @@ class RecordingViewSet(
|
||||
)
|
||||
|
||||
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."})
|
||||
recording_id = url_params['recording']
|
||||
|
||||
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)
|
||||
logger.debug("User '%s' lacks permission for attachment", user)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
if not recording.is_saved:
|
||||
logger.debug("Recording '%s' has not been saved", recording)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
# Generate S3 authorization headers using the extracted URL parameters
|
||||
request = utils.generate_s3_authorization_headers(recording.key)
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
@@ -18,46 +17,93 @@ from core.services.marketing import (
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary of extra claims.
|
||||
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
"""
|
||||
return {
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
sub = user_info.get("sub")
|
||||
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
email = user_info.get("email")
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
claims = {
|
||||
"email": email,
|
||||
"full_name": self.compute_full_name(user_info),
|
||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||
}
|
||||
if not user and self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
password="!", # noqa: S106
|
||||
**claims,
|
||||
)
|
||||
|
||||
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||
"""
|
||||
Post-processing after user creation or retrieval.
|
||||
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
|
||||
Args:
|
||||
user (User): The user instance.
|
||||
claims (dict): The claims dictionary.
|
||||
is_new_user (bool): Indicates if the user was newly created.
|
||||
elif not user:
|
||||
return None
|
||||
|
||||
Returns:
|
||||
- None
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
|
||||
"""
|
||||
email = claims["email"]
|
||||
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
self.update_user_if_needed(user, claims)
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
@@ -70,9 +116,7 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
|
||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||
"""
|
||||
with contextlib.suppress(
|
||||
ContactCreationError, ImproperlyConfigured, ImportError
|
||||
):
|
||||
try:
|
||||
marketing_service = get_marketing_service()
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
@@ -80,6 +124,8 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
marketing_service.create_contact(
|
||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
||||
)
|
||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||
pass
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
@@ -96,3 +142,32 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
_("Multiple user accounts share a common email.")
|
||||
) from e
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def compute_full_name(user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
full_name = " ".join(
|
||||
filter(
|
||||
None,
|
||||
(
|
||||
user_info.get(field)
|
||||
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
|
||||
),
|
||||
)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
@staticmethod
|
||||
def update_user_if_needed(user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
user_fields = vars(user.__class__) # Get available model fields
|
||||
updated_claims = {
|
||||
key: value
|
||||
for key, value in claims.items()
|
||||
if value and key in user_fields and value != getattr(user, key)
|
||||
}
|
||||
|
||||
if not updated_claims:
|
||||
return
|
||||
|
||||
User.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Authentication URLs for the People core app."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
urlpatterns = [
|
||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
||||
path(
|
||||
"logout-callback/",
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozzila_oidc_urls,
|
||||
]
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
import copy
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
from mozilla_django_oidc.utils import (
|
||||
absolutify,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
|
||||
Adds support for handling logout callbacks from the identity provider (OP)
|
||||
by initiating the logout flow if the user has an active session.
|
||||
|
||||
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
|
||||
This parameter is crucial for maintaining the integrity of the logout flow between this call
|
||||
and the subsequent callback.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def persist_state(request, state):
|
||||
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
|
||||
|
||||
This method is used to store the OIDC state parameter in the session, according to the
|
||||
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
|
||||
utility function.
|
||||
"""
|
||||
|
||||
if "oidc_states" not in request.session or not isinstance(
|
||||
request.session["oidc_states"], dict
|
||||
):
|
||||
request.session["oidc_states"] = {}
|
||||
|
||||
request.session["oidc_states"][state] = {}
|
||||
request.session.save()
|
||||
|
||||
def construct_oidc_logout_url(self, request):
|
||||
"""Create the redirect URL for interfacing with the OIDC provider.
|
||||
|
||||
Retrieves the necessary parameters from the session and constructs the URL
|
||||
required to initiate logout with the OpenID Connect provider.
|
||||
|
||||
If no ID token is found in the session, the logout flow will not be initiated,
|
||||
and the method will return the default redirect URL.
|
||||
|
||||
The 'state' parameter is generated randomly and persisted in the session to ensure
|
||||
its integrity during the subsequent callback.
|
||||
"""
|
||||
|
||||
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
|
||||
|
||||
if not oidc_logout_endpoint:
|
||||
return self.redirect_url
|
||||
|
||||
reverse_url = reverse("oidc_logout_callback")
|
||||
id_token = request.session.get("oidc_id_token", None)
|
||||
|
||||
if not id_token:
|
||||
return self.redirect_url
|
||||
|
||||
query = {
|
||||
"id_token_hint": id_token,
|
||||
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
|
||||
"post_logout_redirect_uri": absolutify(request, reverse_url),
|
||||
}
|
||||
|
||||
self.persist_state(request, query["state"])
|
||||
|
||||
return f"{oidc_logout_endpoint}?{urlencode(query)}"
|
||||
|
||||
def post(self, request):
|
||||
"""Handle user logout.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, constructs the OIDC logout URL and redirects the user to start
|
||||
the logout process.
|
||||
|
||||
If the user is redirected to the default logout URL, ensure her Django session
|
||||
is terminated.
|
||||
"""
|
||||
|
||||
logout_url = self.redirect_url
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logout_url = self.construct_oidc_logout_url(request)
|
||||
|
||||
# If the user is not redirected to the OIDC provider, ensure logout
|
||||
if logout_url == self.redirect_url:
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(logout_url)
|
||||
|
||||
|
||||
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
|
||||
|
||||
Handles the callback after logout from the identity provider (OP).
|
||||
Verifies the state parameter and performs necessary logout actions.
|
||||
|
||||
The Django session is maintained during the logout process to ensure the integrity
|
||||
of the logout flow initiated in the previous step.
|
||||
"""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request):
|
||||
"""Handle the logout callback.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, verifies the state parameter and performs necessary logout actions.
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
state = request.GET.get("state")
|
||||
|
||||
if state not in request.session.get("oidc_states", {}):
|
||||
msg = "OIDC callback state not found in session `oidc_states`!"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
del request.session["oidc_states"][state]
|
||||
request.session.save()
|
||||
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
"""Custom callback view for handling the silent login flow."""
|
||||
|
||||
@property
|
||||
def failure_url(self):
|
||||
"""Override the failure URL property to handle silent login flow
|
||||
|
||||
A silent login failure (e.g., no active user session) should not be
|
||||
considered as an authentication failure.
|
||||
"""
|
||||
if self.request.session.get("silent", None):
|
||||
del self.request.session["silent"]
|
||||
self.request.session.save()
|
||||
return self.success_url
|
||||
return super().failure_url
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
||||
"""Custom authentication view for handling the silent login flow."""
|
||||
|
||||
def get_extra_params(self, request):
|
||||
"""Handle 'prompt' extra parameter for the silent login flow
|
||||
|
||||
This extra parameter is necessary to distinguish between a standard
|
||||
authentication flow and the silent login flow.
|
||||
"""
|
||||
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
|
||||
if extra_params is None:
|
||||
extra_params = {}
|
||||
if request.GET.get("silent") == "true":
|
||||
extra_params = copy.deepcopy(extra_params)
|
||||
extra_params.update({"prompt": "none"})
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
return extra_params
|
||||
@@ -1,22 +1,22 @@
|
||||
"""
|
||||
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
|
||||
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
|
||||
RECORDING_FOLDER = 'recordings'
|
||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
|
||||
f"/media/recordings/(?P<recording>{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__)
|
||||
|
||||
|
||||
@@ -582,55 +578,16 @@ class Recording(BaseModel):
|
||||
|
||||
@property
|
||||
def is_saved(self) -> bool:
|
||||
"""Check if the recording is in a saved state."""
|
||||
"""Wip"""
|
||||
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()
|
||||
|
||||
"""Wip."""
|
||||
extension = "ogg" if self.mode == RecordingModeChoices.TRANSCRIPT else "mp4"
|
||||
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{extension}"
|
||||
|
||||
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"
|
||||
@@ -4,10 +4,11 @@ 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 _
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.mail import send_mail
|
||||
|
||||
import requests
|
||||
|
||||
@@ -37,12 +38,11 @@ class NotificationService:
|
||||
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""
|
||||
Send an email notification to recording owners when their recording is ready.
|
||||
"""Wip."""
|
||||
|
||||
The email includes a direct link that redirects owners to a dedicated download
|
||||
page in the frontend where they can access their specific recording.
|
||||
"""
|
||||
# subject
|
||||
# emails
|
||||
domain = Site.objects.get_current().domain
|
||||
|
||||
owner_accesses = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
@@ -50,63 +50,50 @@ class NotificationService:
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.order_by("created_at")
|
||||
.all()
|
||||
)
|
||||
|
||||
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}",
|
||||
}
|
||||
language = get_language()
|
||||
|
||||
has_failures = False
|
||||
context = (
|
||||
{
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": domain,
|
||||
"room_name": recording.room.name,
|
||||
"recording_date": recording.created_at.strftime("%A %d %B %Y"),
|
||||
"recording_time": recording.created_at.strftime("%H:%M"),
|
||||
"link": f"{domain}/recordings/{recording.id}",
|
||||
}
|
||||
)
|
||||
|
||||
# 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
|
||||
emails = [access.user.email for access in owner_accesses]
|
||||
|
||||
with override(language):
|
||||
msg_html = render_to_string("mail/html/screen_recording.html", context)
|
||||
msg_plain = render_to_string("mail/text/screen_recording.txt", context)
|
||||
subject = str(_("Your recording is ready")) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
msg_plain = render_to_string(
|
||||
"mail/text/screen_recording.txt", personalized_context
|
||||
)
|
||||
subject = str(_("Your recording is ready")) # Force translation
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification to %s was not sent: %s", emails, exception)
|
||||
return False
|
||||
|
||||
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 True
|
||||
|
||||
return not has_failures
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
|
||||
@@ -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,26 +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
|
||||
except Exception as e:
|
||||
raise WorkerConnectionError(
|
||||
f"Unexpected error during LiveKit client connection: {str(e)}"
|
||||
) from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop an ongoing egress worker.
|
||||
@@ -94,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,
|
||||
@@ -105,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")
|
||||
@@ -126,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(
|
||||
|
||||
@@ -58,7 +58,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.has_usable_password() is False
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
assert user.email == email
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.has_usable_password() is False
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_authentication_getter_new_user_with_names(monkeypatch, email):
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.has_usable_password() is False
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Claims verification failed",
|
||||
match="User info contained no recognizable user identification",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Unit tests for the Authentication URLs."""
|
||||
|
||||
from core.authentication.urls import urlpatterns
|
||||
|
||||
|
||||
def test_urls_override_default_mozilla_django_oidc():
|
||||
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
|
||||
|
||||
url_names = [u.name for u in urlpatterns]
|
||||
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import (
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationRequestView,
|
||||
OIDCLogoutCallbackView,
|
||||
OIDCLogoutView,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_anonymous():
|
||||
"""Anonymous users calling the logout url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
||||
)
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = client.get(url)
|
||||
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
||||
)
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
response = client.get(url)
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/default-redirect-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback_anonymous():
|
||||
"""Anonymous users calling the logout callback url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_oidc_states",
|
||||
[{}, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_oidc_states:
|
||||
request.session["oidc_states"] = initial_oidc_states
|
||||
request.session.save()
|
||||
|
||||
mocked_state = "mock_state"
|
||||
|
||||
OIDCLogoutView().persist_state(request, mocked_state)
|
||||
|
||||
assert "oidc_states" in request.session
|
||||
assert request.session["oidc_states"] == {
|
||||
"mock_state": {},
|
||||
**initial_oidc_states,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
||||
def test_view_logout_construct_oidc_logout_url(
|
||||
mocked_get_random_string, mocked_persist_state
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
||||
request.session.save()
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
mocked_persist_state.assert_called_once()
|
||||
mocked_get_random_string.assert_called_once()
|
||||
|
||||
params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
||||
assert params["state"][0] == "mocked_state"
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
assert url in params["post_logout_redirect_uri"][0]
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
||||
def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
assert redirect_url == "/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[None, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_state:
|
||||
request.session["oidc_states"] = initial_state
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
||||
callback_view(request)
|
||||
|
||||
assert (
|
||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
|
||||
request.session["oidc_states"] = {mocked_state: {}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
|
||||
def clear_user(request):
|
||||
# Assert state is cleared prior to logout
|
||||
assert request.session["oidc_states"] == {}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
mock_logout.side_effect = clear_user
|
||||
response = callback_view(request)
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_default(settings, mocked_extra_params_setting):
|
||||
"""By default, authentication request should not trigger silent login."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {}
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
|
||||
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "foo"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
assert not request.session.get("silent")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
|
||||
"""If 'silent' parameter is set to True, the silent login should be triggered."""
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "true"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
expected_params = {"prompt": "none"}
|
||||
|
||||
assert (
|
||||
extra_params == {**mocked_extra_params_setting, **expected_params}
|
||||
if mocked_extra_params_setting
|
||||
else expected_params
|
||||
)
|
||||
assert request.session.get("silent") is True
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
"failure_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url(mocked_failure_url):
|
||||
"""Test default behavior of the 'failure_url' property"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_failure_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCAuthenticationCallbackView,
|
||||
"success_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url_silent_login(mocked_success_url):
|
||||
"""If a silent login was initiated and failed, it should not be treated as a failure."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_success_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
assert not request.session.get("silent")
|
||||
@@ -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"
|
||||
|
||||
@@ -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,22 +336,23 @@ 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 not be allowed to see related users.
|
||||
Users who are members of a room should be allowed to see related users.
|
||||
"""
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = UserFactory()
|
||||
other_user = UserFactory()
|
||||
|
||||
room = RoomFactory()
|
||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||
user_access = UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
other_user_access = UserResourceAccessFactory(
|
||||
resource=room, user=other_user, role="member"
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(3):
|
||||
with django_assert_num_queries(4):
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
@@ -359,7 +360,33 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
assert response.status_code == 200
|
||||
content_dict = response.json()
|
||||
|
||||
assert "accesses" not in content_dict
|
||||
assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
},
|
||||
{
|
||||
"id": str(other_user_access.id),
|
||||
"user": {
|
||||
"id": str(other_user_access.user.id),
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
@@ -388,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()
|
||||
@@ -424,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,
|
||||
@@ -437,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)
|
||||
@@ -3,10 +3,10 @@
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.authentication.urls import urlpatterns as oidc_urls
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
|
||||
@@ -11,11 +11,12 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from livekit.api import AccessToken, VideoGrants
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
import botocore
|
||||
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
|
||||
|
||||
|
||||
def generate_color(identity: str) -> str:
|
||||
@@ -126,6 +127,7 @@ def generate_s3_authorization_headers(key):
|
||||
- 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,
|
||||
@@ -143,18 +145,3 @@ def generate_s3_authorization_headers(key):
|
||||
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)
|
||||
|
||||
Binary file not shown.
@@ -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 ""
|
||||
|
||||
Binary file not shown.
@@ -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 ""
|
||||
|
||||
Binary file not shown.
@@ -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")),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -263,9 +261,6 @@ class Base(Configuration):
|
||||
"rest_framework.parsers.JSONParser",
|
||||
"nested_multipart_parser.drf.DrfNestedParser",
|
||||
],
|
||||
"DEFAULT_RENDERER_CLASSES": [
|
||||
"rest_framework.renderers.JSONRenderer",
|
||||
],
|
||||
"EXCEPTION_HANDLER": "core.api.exception_handler",
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||
"PAGE_SIZE": 20,
|
||||
@@ -312,12 +307,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
|
||||
@@ -332,8 +321,6 @@ class Base(Configuration):
|
||||
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"
|
||||
|
||||
@@ -362,8 +349,8 @@ class Base(Configuration):
|
||||
SESSION_COOKIE_AGE = 60 * 60 * 12
|
||||
|
||||
# OIDC - Authorization Code Flow
|
||||
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
|
||||
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
|
||||
OIDC_CREATE_USER = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
|
||||
)
|
||||
@@ -397,9 +384,6 @@ class Base(Configuration):
|
||||
OIDC_OP_USER_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
|
||||
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
|
||||
)
|
||||
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
||||
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
@@ -446,11 +430,6 @@ class Base(Configuration):
|
||||
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
||||
environ_prefix=None,
|
||||
)
|
||||
OIDC_USERINFO_ESSENTIAL_CLAIMS = values.ListValue(
|
||||
default=[],
|
||||
environ_name="OIDC_USERINFO_ESSENTIAL_CLAIMS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Video conference configuration
|
||||
LIVEKIT_CONFIGURATION = {
|
||||
@@ -460,9 +439,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
|
||||
)
|
||||
@@ -477,6 +453,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",
|
||||
@@ -499,20 +478,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(
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.21"
|
||||
version = "0.1.17"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,14 +25,13 @@ 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",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-lasuite==0.0.7",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
@@ -54,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",
|
||||
]
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.17",
|
||||
"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.4",
|
||||
"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.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz",
|
||||
"integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.17",
|
||||
"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.4",
|
||||
"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',
|
||||
|
||||
@@ -13,17 +13,14 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
const isSDKContext = useIsSdkContext()
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<AppInitialization />
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@ import { silenceLiveKitLogs } from '@/utils/livekit'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||
import { useLocation } from 'wouter'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
export const AppInitialization = () => {
|
||||
const { data } = useConfig()
|
||||
const [location] = useLocation()
|
||||
|
||||
const {
|
||||
analytics = {},
|
||||
@@ -12,8 +16,10 @@ export const AppInitialization = () => {
|
||||
silence_livekit_debug_logs = false,
|
||||
} = data || {}
|
||||
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
const isSDKContext = location.includes(SDK_BASE_ROUTE)
|
||||
|
||||
useAnalytics({ ...analytics, isDisabled: isSDKContext })
|
||||
useSupport({ ...support, isDisabled: isSDKContext })
|
||||
|
||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||
|
||||
|
||||
@@ -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_FEEDBACKS_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_FEEDBACKS_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',
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
+1
-13
@@ -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 +1,19 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { RecordingMode, RecordingStatus } from '@/features/recording'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
|
||||
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 }) => {
|
||||
export const fetchRecording = ({
|
||||
recordingId,
|
||||
}: {
|
||||
roomId: string
|
||||
username?: string
|
||||
}) => {
|
||||
return fetchApi<RecordingApi>(`/recordings/${recordingId}/`)
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { A, Button, Dialog, Div, H, P, 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,
|
||||
useIsRecordingTransitioning,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
} from '@/features/recording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
|
||||
|
||||
import {
|
||||
NotificationType,
|
||||
useNotifyParticipants,
|
||||
} 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 [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
|
||||
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 isRoomConnected = room.state == ConnectionState.Connected
|
||||
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 ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
|
||||
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 || isPendingToStop ? (
|
||||
<>
|
||||
<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 || isPendingToStart ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('start.button')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
import { A, Button, Dialog, Div, H, LinkButton, P, 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 { ConnectionState, 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 [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
|
||||
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()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
|
||||
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 ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
|
||||
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 || isPendingToStop ? (
|
||||
<>
|
||||
<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 || isPendingToStart ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('start.button')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
</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,18 +1 @@
|
||||
// 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'
|
||||
export { Recording as RecordingRoute } from './routes/Recording'
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Center, VStack } from '@/styled-system/jsx'
|
||||
import { Bold, Button, Field, H, Input, LinkButton, Text } from '@/primitives'
|
||||
import { Screen } from '@/layout/Screen.tsx'
|
||||
import { useParams } from 'wouter'
|
||||
import { fetchRecording } from '../api/fetchRecording'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { UserAware, useUser } from '@/features/auth'
|
||||
import { CenteredContent } from '@/layout/CenteredContent.tsx'
|
||||
import { useState } from 'react'
|
||||
import fourthSlide from '@/assets/intro-slider/4_record.png'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording.ts'
|
||||
|
||||
function formatDate(date, format = 'YYYY-MM-DD') {
|
||||
// Accept Date object or create one from string
|
||||
const dateObj = date instanceof Date ? date : new Date(date)
|
||||
|
||||
// Check if the date is valid
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return 'Invalid Date'
|
||||
}
|
||||
|
||||
const year = dateObj.getFullYear()
|
||||
const month = dateObj.getMonth() + 1 // getMonth() returns 0-11
|
||||
const day = dateObj.getDate()
|
||||
const hours = dateObj.getHours()
|
||||
const minutes = dateObj.getMinutes()
|
||||
const seconds = dateObj.getSeconds()
|
||||
|
||||
// Pad numbers with leading zeros
|
||||
const pad = (num) => String(num).padStart(2, '0')
|
||||
|
||||
// Replace format tokens with actual values
|
||||
return format
|
||||
.replace('YYYY', year)
|
||||
.replace('MM', pad(month))
|
||||
.replace('DD', pad(day))
|
||||
.replace('HH', pad(hours))
|
||||
.replace('mm', pad(minutes))
|
||||
.replace('ss', pad(seconds))
|
||||
}
|
||||
|
||||
export const Recording = () => {
|
||||
const { recordingId } = useParams()
|
||||
const { isLoggedIn } = useUser()
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['recording', recordingId],
|
||||
queryFn: () => fetchRecording({ recordingId }),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<CenteredContent>
|
||||
<Spinner />
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !isLoggedIn) {
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<CenteredContent title={'lien invalide'} withBackButton>
|
||||
<Text centered>
|
||||
Vous ne pouvez pas accéder à ce lien d'enregistrement
|
||||
</Text>
|
||||
</CenteredContent>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Center>
|
||||
<VStack>
|
||||
<img
|
||||
src={fourthSlide}
|
||||
alt={''}
|
||||
className={css({
|
||||
maxHeight: '309px',
|
||||
})}
|
||||
/>
|
||||
<H lvl={1} centered>
|
||||
Votre enregistrement est prêt !
|
||||
</H>
|
||||
<Text centered margin="md">
|
||||
Enregistrement de la réunion <b>{data.room.name}</b> du{' '}
|
||||
{formatDate(data.created_at, 'YYYY-MM-DD HH:mm')}. <br /> Cet
|
||||
enregistrement est disponible <u>pendant 7 jours</u>.
|
||||
</Text>
|
||||
<LinkButton
|
||||
href={`https://meet.127.0.0.1.nip.io/media/recordings/${data.id}.${data.mode == RecordingMode.ScreenRecording ? 'mp4' : 'ogg'}`}
|
||||
download={`${data.room.name}-${formatDate(data.created_at)}`}
|
||||
>
|
||||
Télécharger
|
||||
</LinkButton>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
+6
-3
@@ -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
|
||||
@@ -27,6 +31,5 @@ export function useStartRecording(
|
||||
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
|
||||
mutationFn: startRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
})
|
||||
}
|
||||
+1
-2
@@ -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
|
||||
@@ -19,6 +19,5 @@ export function useStopRecording(
|
||||
return useMutation<ApiRoom, ApiError, StopRecordingParams>({
|
||||
mutationFn: stopRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
})
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
+11
-80
@@ -9,42 +9,15 @@ 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',
|
||||
keyPrefix: 'recordingToast',
|
||||
})
|
||||
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
|
||||
@@ -125,12 +98,6 @@ export const RecordingStateToast = () => {
|
||||
|
||||
if (!key) return
|
||||
|
||||
const isStarted = key?.includes('started')
|
||||
|
||||
const hasScreenRecordingAccessAndActive =
|
||||
isScreenRecordingActive && hasScreenRecordingAccess
|
||||
const hasTranscriptAccessAndActive = isTranscriptActive && hasTranscriptAccess
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -140,7 +107,7 @@ export const RecordingStateToast = () => {
|
||||
left: '10px',
|
||||
paddingY: '0.25rem',
|
||||
paddingX: '0.75rem 0.75rem',
|
||||
backgroundColor: 'danger.700',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
borderColor: 'white',
|
||||
border: '1px solid',
|
||||
color: 'white',
|
||||
@@ -148,51 +115,15 @@ export const RecordingStateToast = () => {
|
||||
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>
|
||||
)}
|
||||
<Spinner size={20} variant="dark" />
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
fontWeight: '500 !important',
|
||||
})}
|
||||
>
|
||||
{t(key)}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { A, Button, Div, 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,
|
||||
} from '@/features/rooms/api/startRecording'
|
||||
import { useStopRecording } from '@/features/rooms/api/stopRecording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
|
||||
import { useIsRecordingTransitioning } from '../hooks/useIsRecordingTransitioning'
|
||||
import {
|
||||
useIsScreenRecordingStarted,
|
||||
useIsTranscriptStarted,
|
||||
} from '../hooks/useIsRecordingStarted'
|
||||
|
||||
export const ScreenRecording = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
|
||||
const isScreenRecordingStarted = useIsScreenRecordingStarted()
|
||||
const isTranscriptStarted = useIsTranscriptStarted()
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const notifyParticipant = async (status: NotificationType) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
type: status,
|
||||
}
|
||||
const data = encoder.encode(JSON.stringify(payload))
|
||||
await room.localParticipant.publishData(data, {
|
||||
reliable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handleScreenRecording = async () => {
|
||||
if (!roomId) {
|
||||
console.warn('No room ID found')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
await notifyParticipant(NotificationType.ScreenRecordingStopped)
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
} else {
|
||||
await startRecordingRoom({
|
||||
id: roomId,
|
||||
mode: RecordingMode.ScreenRecording,
|
||||
})
|
||||
await notifyParticipant(NotificationType.ScreenRecordingStarted)
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() => isLoading || isRecordingTransitioning || isTranscriptStarted,
|
||||
[isLoading, isRecordingTransitioning, isTranscriptStarted]
|
||||
)
|
||||
|
||||
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',
|
||||
})}
|
||||
/>
|
||||
|
||||
{isScreenRecordingStarted ? (
|
||||
<>
|
||||
<Text>{t('stop.heading')}</Text>
|
||||
<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-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>{t('start.heading')}</Text>
|
||||
<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-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('start.button')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { Chat } from '../prefabs/Chat'
|
||||
import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -110,10 +109,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 +120,7 @@ const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{keepAlive || isOpen ? children : null}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -135,7 +133,6 @@ export const SidePanel = () => {
|
||||
isSidePanelOpen,
|
||||
isToolsOpen,
|
||||
isAdminOpen,
|
||||
isInfoOpen,
|
||||
isSubPanelOpen,
|
||||
activeSubPanelId,
|
||||
} = useSidePanel()
|
||||
@@ -161,7 +158,7 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isEffectsOpen}>
|
||||
<Effects />
|
||||
</Panel>
|
||||
<Panel isOpen={isChatOpen} keepAlive={true}>
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
<Panel isOpen={isToolsOpen}>
|
||||
@@ -170,9 +167,6 @@ export const SidePanel = () => {
|
||||
<Panel isOpen={isAdminOpen}>
|
||||
<Admin />
|
||||
</Panel>
|
||||
<Panel isOpen={isInfoOpen}>
|
||||
<Info />
|
||||
</Panel>
|
||||
</StyledSidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,25 +4,18 @@ import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CRISP_HELP_ARTICLE_MORE_TOOLS } from '@/utils/constants'
|
||||
import { ReactNode } from 'react'
|
||||
import { Transcript } from './Transcript'
|
||||
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import {
|
||||
useIsRecordingModeEnabled,
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
TranscriptSidePanel,
|
||||
ScreenRecordingSidePanel,
|
||||
useIsRecordingActive,
|
||||
} from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { ScreenRecording } from '@/features/rooms/livekit/components/ScreenRecording'
|
||||
import { useIsRecordingEnabled } from '../hooks/useIsRecordingEnabled'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
|
||||
export interface ToolsButtonProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
description: string
|
||||
onPress: () => void
|
||||
isBetaFeature?: boolean
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const ToolButton = ({
|
||||
@@ -30,8 +23,6 @@ const ToolButton = ({
|
||||
title,
|
||||
description,
|
||||
onPress,
|
||||
isBetaFeature = false,
|
||||
isActive = false,
|
||||
}: ToolsButtonProps) => {
|
||||
return (
|
||||
<RACButton
|
||||
@@ -62,47 +53,13 @@ const ToolButton = ({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
{isBetaFeature && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
backgroundColor: 'primary.50',
|
||||
color: 'primary.800',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
borderRadius: '4px',
|
||||
paddingX: '4px',
|
||||
paddingBottom: '1px',
|
||||
bottom: -8,
|
||||
right: -8,
|
||||
})}
|
||||
>
|
||||
BETA
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text
|
||||
margin={false}
|
||||
as="h3"
|
||||
className={css({ display: 'flex', gap: 0.25 })}
|
||||
>
|
||||
<Text margin={false} as="h3">
|
||||
{title}
|
||||
{isActive && (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primary.500',
|
||||
height: '10px',
|
||||
width: '10px',
|
||||
marginTop: '5px',
|
||||
borderRadius: '100%',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
<Text as="p" variant="smNote" wrap="pretty">
|
||||
{description}
|
||||
@@ -115,28 +72,18 @@ const ToolButton = ({
|
||||
export const Tools = () => {
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId } =
|
||||
useSidePanel()
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
const isTranscriptEnabled = useIsRecordingEnabled(RecordingMode.Transcript)
|
||||
|
||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||
RecordingMode.Transcript
|
||||
)
|
||||
|
||||
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
|
||||
const isScreenRecordingActive = useIsRecordingActive(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
// FIXME - use settings returned by backend
|
||||
const isRecordingEnabled = true
|
||||
|
||||
switch (activeSubPanelId) {
|
||||
case SubPanelId.TRANSCRIPT:
|
||||
return <TranscriptSidePanel />
|
||||
return <Transcript />
|
||||
case SubPanelId.SCREEN_RECORDING:
|
||||
return <ScreenRecordingSidePanel />
|
||||
return <ScreenRecording />
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -171,18 +118,14 @@ export const Tools = () => {
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={() => openTranscript()}
|
||||
isBetaFeature
|
||||
isActive={isTranscriptActive}
|
||||
/>
|
||||
)}
|
||||
{hasScreenRecordingAccess && (
|
||||
{isRecordingEnabled && (
|
||||
<ToolButton
|
||||
icon={<RiLiveFill size={24} color="white" />}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={() => openScreenRecording()}
|
||||
isBetaFeature
|
||||
isActive={isScreenRecordingActive}
|
||||
/>
|
||||
)}
|
||||
</Div>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { A, Button, Div, 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,
|
||||
useStartRecording,
|
||||
} from '@/features/rooms/api/startRecording'
|
||||
import { useStopRecording } from '@/features/rooms/api/stopRecording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
|
||||
import {
|
||||
BETA_USERS_FORM_URL,
|
||||
CRISP_HELP_ARTICLE_TRANSCRIPT,
|
||||
} from '@/utils/constants'
|
||||
import { useIsRecordingTransitioning } from '../hooks/useIsRecordingTransitioning'
|
||||
import {
|
||||
useIsScreenRecordingStarted,
|
||||
useIsTranscriptStarted,
|
||||
} from '../hooks/useIsRecordingStarted'
|
||||
|
||||
export const Transcript = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
|
||||
|
||||
const hasTranscriptAccess = useHasTranscriptAccess()
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom } = useStartRecording()
|
||||
const { mutateAsync: stopRecordingRoom } = useStopRecording()
|
||||
|
||||
const isScreenRecordingStarted = useIsScreenRecordingStarted()
|
||||
const isTranscriptStarted = useIsTranscriptStarted()
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const notifyParticipant = async (status: NotificationType) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
type: status,
|
||||
}
|
||||
const data = encoder.encode(JSON.stringify(payload))
|
||||
await room.localParticipant.publishData(data, {
|
||||
reliable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handleTranscript = async () => {
|
||||
if (!roomId) {
|
||||
console.warn('No room ID found')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
await notifyParticipant(NotificationType.TranscriptionStopped)
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
} else {
|
||||
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
|
||||
await notifyParticipant(NotificationType.TranscriptionStarted)
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() => isLoading || isRecordingTransitioning || isScreenRecordingStarted,
|
||||
[isLoading, isRecordingTransitioning, isScreenRecordingStarted]
|
||||
)
|
||||
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isTranscriptStarted ? (
|
||||
<>
|
||||
<Text>{t('stop.heading')}</Text>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>{t('start.heading')}</Text>
|
||||
<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"
|
||||
>
|
||||
{t('start.button')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiInformationLine } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
export const InfoToggle = ({
|
||||
onPress,
|
||||
...props
|
||||
}: Partial<ToggleButtonProps>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' })
|
||||
|
||||
const { isInfoOpen, toggleInfo } = useSidePanel()
|
||||
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryTextDark"
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isInfoOpen}
|
||||
onPress={(e) => {
|
||||
toggleInfo()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-info-${tooltipLabel}`}
|
||||
{...props}
|
||||
>
|
||||
<RiInformationLine />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-5
@@ -2,17 +2,14 @@ import { RiMegaphoneLine } from '@remixicon/react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
|
||||
export const FeedbackMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.feedback?.url) return
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
href={data?.feedback?.url}
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
target="_blank"
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
|
||||
+8
-10
@@ -33,20 +33,19 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
const label = isLocal(participant)
|
||||
? t('participants.muteYourself')
|
||||
: t('participants.muteParticipant', {
|
||||
name,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
square
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={label}
|
||||
aria-label={label}
|
||||
tooltip={
|
||||
isLocal(participant)
|
||||
? t('participants.muteYourself')
|
||||
: t('participants.muteParticipant', {
|
||||
name,
|
||||
})
|
||||
}
|
||||
isDisabled={isMuted}
|
||||
onPress={() =>
|
||||
!isMuted && isLocal(participant)
|
||||
@@ -56,7 +55,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
data-attr="participants-mute"
|
||||
>
|
||||
{isMuted ? (
|
||||
<RiMicOffFill color={'gray'} aria-hidden={true} />
|
||||
<RiMicOffFill color={'gray'} />
|
||||
) : (
|
||||
<RiMicFill
|
||||
className={css({
|
||||
@@ -65,7 +64,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
? 'pulse_background 800ms infinite'
|
||||
: undefined,
|
||||
})}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
||||
import { Participant } from 'livekit-client'
|
||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
|
||||
@@ -57,12 +56,6 @@ export const ReactionsToggle = () => {
|
||||
}, ANIMATION_DURATION)
|
||||
}
|
||||
|
||||
const debouncedSendReaction = useRateLimiter({
|
||||
callback: sendReaction,
|
||||
maxCalls: 10,
|
||||
windowMs: 1000,
|
||||
})
|
||||
|
||||
// Custom animation implementation for the emoji toolbar
|
||||
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
|
||||
// animation isn't perfect
|
||||
@@ -134,7 +127,7 @@ export const ReactionsToggle = () => {
|
||||
{EMOJIS.map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => debouncedSendReaction(emoji)}
|
||||
onPress={() => sendReaction(emoji)}
|
||||
aria-label={t('send', { emoji })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasFaceLandmarksAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
|
||||
const featureEnabled = useFeatureFlagEnabled('face-landmarks')
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
return featureEnabled || !isAnalyticsEnabled
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useIsRecordingEnabled } from './useIsRecordingEnabled'
|
||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
||||
|
||||
export const useHasScreenRecordingAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled('screen-recording')
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isScreenRecordingEnabled = useIsRecordingEnabled(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isAdminOrOwner &&
|
||||
isScreenRecordingEnabled
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
||||
import { useIsRecordingEnabled } from './useIsRecordingEnabled'
|
||||
|
||||
export const useHasTranscriptAccess = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isTranscriptEnabled = useIsRecordingEnabled(RecordingMode.Transcript)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isAdminOrOwner &&
|
||||
isTranscriptEnabled
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { RecordingMode } from '../types'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const useIsRecordingModeEnabled = (mode: RecordingMode) => {
|
||||
export const useIsRecordingEnabled = (mode: RecordingMode) => {
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const useIsScreenRecordingStarted = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
return recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED
|
||||
}
|
||||
|
||||
export const useIsTranscriptStarted = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
return recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export enum PanelId {
|
||||
CHAT = 'chat',
|
||||
TOOLS = 'tools',
|
||||
ADMIN = 'admin',
|
||||
INFO = 'info',
|
||||
}
|
||||
|
||||
export enum SubPanelId {
|
||||
@@ -25,7 +24,6 @@ export const useSidePanel = () => {
|
||||
const isChatOpen = activePanelId == PanelId.CHAT
|
||||
const isToolsOpen = activePanelId == PanelId.TOOLS
|
||||
const isAdminOpen = activePanelId == PanelId.ADMIN
|
||||
const isInfoOpen = activePanelId == PanelId.INFO
|
||||
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
|
||||
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
|
||||
const isSidePanelOpen = !!activePanelId
|
||||
@@ -56,11 +54,6 @@ export const useSidePanel = () => {
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleInfo = () => {
|
||||
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const openTranscript = () => {
|
||||
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
@@ -79,7 +72,6 @@ export const useSidePanel = () => {
|
||||
toggleEffects,
|
||||
toggleTools,
|
||||
toggleAdmin,
|
||||
toggleInfo,
|
||||
openTranscript,
|
||||
openScreenRecording,
|
||||
isSubPanelOpen,
|
||||
@@ -89,7 +81,6 @@ export const useSidePanel = () => {
|
||||
isSidePanelOpen,
|
||||
isToolsOpen,
|
||||
isAdminOpen,
|
||||
isInfoOpen,
|
||||
isTranscriptOpen,
|
||||
isScreenRecordingOpen,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
RiMore2Line,
|
||||
RiSettings3Line,
|
||||
} from '@remixicon/react'
|
||||
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
|
||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
@@ -23,7 +24,6 @@ import { useSettingsDialog } from '../../components/controls/SettingsDialogConte
|
||||
import { ResponsiveMenu } from './ResponsiveMenu'
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export function MobileControlBar({
|
||||
onDeviceError,
|
||||
@@ -38,8 +38,6 @@ export function MobileControlBar({
|
||||
const { toggleEffects } = useSidePanel()
|
||||
const { setDialogOpen } = useSettingsDialog()
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -152,19 +150,17 @@ export function MobileControlBar({
|
||||
>
|
||||
<RiAccountBoxLine size={20} />
|
||||
</Button>
|
||||
{data?.feedback?.url && (
|
||||
<LinkButton
|
||||
href={data?.feedback?.url}
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
)}
|
||||
<LinkButton
|
||||
href={GRIST_FEEDBACKS_FORM}
|
||||
variant="primaryTextDark"
|
||||
tooltip={t('options.items.feedback')}
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setDialogOpen(true)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { css } from '@/styled-system/css'
|
||||
import { ChatToggle } from '../../components/controls/ChatToggle'
|
||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||
import { InfoToggle } from '../../components/controls/InfoToggle'
|
||||
import { AdminToggle } from '../../components/AdminToggle'
|
||||
import { useSize } from '../../hooks/useResizeObserver'
|
||||
import { useState, RefObject } from 'react'
|
||||
@@ -19,7 +18,6 @@ const NavigationControls = ({
|
||||
tooltipType = 'instant',
|
||||
}: Partial<ToggleButtonProps>) => (
|
||||
<>
|
||||
<InfoToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
<ChatToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
<ParticipantsToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
<ToolsToggle onPress={onPress} tooltipType={tooltipType} />
|
||||
|
||||
@@ -28,7 +28,7 @@ import { FocusLayout } from '../components/FocusLayout'
|
||||
import { ParticipantTile } from '../components/ParticipantTile'
|
||||
import { SidePanel } from '../components/SidePanel'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { RecordingStateToast } from '@/features/recording'
|
||||
import { RecordingStateToast } from '../components/RecordingStateToast'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user