Compare commits

...

31 Commits

Author SHA1 Message Date
lebaudantoine d126119ff0 🔧(backend) add backend toggle for silent login feature
Implement configuration option in backend to enable or disable silent login
functionality. Provides flexibility to control this authentication behavior
through server settings.

Requested by user self-hosting the project. Not all OIDC provider support
prompt=none param.
2025-04-16 20:52:59 +02:00
lebaudantoine 83d04c499b ⬆️ (dependencies) upgrade boto3 to more recent version
Update boto3 dependency to latest stable release to benefit from bug fixes,
performance improvements, and expanded AWS service support.
2025-04-16 12:13:42 +02:00
lebaudantoine 41c1f41ed2 (backend) add authenticated recording file access method
Implement secure recording file access through authentication instead of
exposing S3 bucket or using temporary signed links with loose permissions.
Inspired by docs and @spaccoud's implementation, with comprehensive
viewset checks to prevent unauthorized recording downloads.

The ingress reserved to media intercept the original request, and thanks to
Nginx annotations, check with the backend if the user is allowed to donwload
this recording file. This might introduce a dependency to Nginx in the project
by the way.

Note: Tests are integration-based rather than unit tests, requiring minio in
the compose stack and CI environment. Implementation includes known botocore
deprecation warnings that per GitHub issues won't be resolved for months.
2025-04-16 12:13:42 +02:00
lebaudantoine dc06b55693 (backend) enable retrieve viewset on recording model
Add Django built-in mixins to recording viewset to support individual record
retrieval. Enables frontend to access single recording details needed for
the upcoming download page implementation.
2025-04-16 12:13:42 +02:00
lebaudantoine 20aceb1932 (backend) add property to check if recording file is saved
Introduce new property that verifies if a recording file has a saved
status. While the implementation is straightforward, it improves code
readability and provides a clear, semantic way to check file status.
2025-04-16 12:13:42 +02:00
lebaudantoine 3671f2a0dd ♻️(backend) encapsulate recording key and extension logic as properties
Move logic for calculating recording keys and file extensions into proper
properties on recording objects. Simplifies access to Minio storage keys
and clearly documents expected behavior when saving recordings across the
application.
2025-04-16 12:13:42 +02:00
lebaudantoine d74dd967af ♻️(backend) replace hardcoded file extensions with Python enum
Convert hardcoded string file extensions into a well-defined Python enum.
Improves type safety and centralizes extension definitions for better
maintainability and consistency across the codebase.

It was dirty manipulating literals for file extension validation …
2025-04-16 12:13:42 +02:00
lebaudantoine 3a4f4e7016 (backend) add recording mode to serialized fields
Include recording mode in serialized data to enable conditional UI elements
in frontend. Allows download controls to be dynamically enabled or disabled
based on the specific recording type being used.

Screen recording will be downloadable when transcript won't.
2025-04-16 12:13:42 +02:00
lebaudantoine b7d964db56 (backend) add email notifications for screen recordings
Implement backend method to send email notifications when screen recordings
are ready for download. Enables users to be alerted when their recordings are
available. Frontend implementation to follow in upcoming commits.

This service is triggered by the storage hook from Minio.

Add minimal unit test coverage for notification service, addressing previous
lack of tests in this area. The notification service was responsible for
calling the unstable summary service feature, which was developped way too
quickly.

The email template has been reviewed by a LLM, to make it user-friendly and
crystal clear.
2025-04-15 13:46:57 +02:00
lebaudantoine 88b7a7dc58 🌐(backend) generate missing translations for backend updates
Regenerate translation files to include all recent backend string changes.
Address backlog of untranslated content that accumulated during recent
development cycles.
2025-04-14 21:00:46 +02:00
lebaudantoine baca9fc001 🌐(frontend) add missing german translations
Oopsie. I forgot some keys for german, add them.
2025-04-11 11:38:21 +02:00
lebaudantoine c432524f2a 🚸(frontend) display option to user with screen recording access
Introduce a feature flag for screen recording to allow gradual
rollout of the feature.
2025-04-11 11:38:21 +02:00
lebaudantoine a079ceef71 🚸(frontend) inform user when recording is saving
Saving a recording can take a bit of time, display a clear message to
inform user it can takes few minutes.
2025-04-11 11:38:21 +02:00
lebaudantoine f0742a0978 📈(frontend) add analytics on screen recording and transcript features
Use posthog to track whether user starts transcript or screen recording.
2025-04-11 11:38:21 +02:00
lebaudantoine fc1b4d7fa7 💄(frontend) replace recording side panel title with proper header
Update recording side panel to use semantic header element instead of plain
text. Improves accessibility by providing proper document structure and
enhances visual hierarchy in the user interface.
2025-04-11 11:38:21 +02:00
lebaudantoine 46f26eb493 ♻️(frontend) extract recording side panel into a dedicated folder
Extract code elements related to recording into a dedicated folder.
2025-04-11 11:38:21 +02:00
lebaudantoine ff09c3d969 ♻️(frontend) factorize notifyParticipants
Extract notifyParticipants in a proper hook to avoid duplication.
2025-04-11 11:38:21 +02:00
lebaudantoine ba9d22f6c8 ♻️(frontend) extract feature flag into a dedicated enum
Extract code elements related to feature flag into a proper enum
to avoid hardcoded literals.
2025-04-11 11:38:21 +02:00
lebaudantoine 94e71ba15d ♻️(frontend) extract recording api into a dedicated folder
Extract code elements related to recording into a dedicated folder.
2025-04-11 11:38:21 +02:00
lebaudantoine 9a1384b188 ♻️(frontend) rename RecordingStateBadge to RecordingStateToast
Rename component to match existing LiveKit naming conventions like
connectionStateToast. Improves naming consistency.
2025-04-11 11:38:21 +02:00
lebaudantoine 695ac47014 ♻️(frontend) extract recording hooks into a dedicated folder
Extract code elements related to recording into a dedicated folder.
2025-04-11 11:38:21 +02:00
lebaudantoine b3c1deeb9c 🍱(frontend) add La Suite logo
Will be necessary for email notifications, when notifying users their
recording are ready for download.
2025-04-11 11:38:21 +02:00
lebaudantoine c08c3efdbb 🩹(frontend) avoid having the two recording modes concurrently
Implement mutual exclusivity between transcript and screen recording modes
to prevent both from being active simultaneously. Add validation logic to
ensure users can only enable one recording type at a time.
2025-04-11 11:38:21 +02:00
lebaudantoine 468d09dc3b (frontend) introde the Screen Recording side panel
Offer one new tool to user, the screen recording side panel.
2025-04-11 11:38:21 +02:00
lebaudantoine a22d052f46 ♻️(frontend) generalize recording availability hook for all types
Extend recording availability and access hook to work with all recording
types instead of being transcript-specific. Create flexible implementation
that determines availability and permissions for screen recordings and
future recording formats.
2025-04-11 11:38:21 +02:00
lebaudantoine 9734df9d5d ♻️(frontend) generalize recording state badge for all recording types
Extend recording state badge component to work with all types of recordings
instead of just transcripts. Create flexible implementation that supports
screen recordings and future recording formats while maintaining consistent
visual indicators.
2025-04-11 11:38:21 +02:00
lebaudantoine f596aae1e8 ♻️(frontend) generalize transcript notification for all recording types
Convert transcript-specific toast notification into a flexible component
that works with any recording type. Create extensible design that can
accommodate screen recordings and future recording formats. Implementation
is functional though not perfect, with room for future enhancement.
2025-04-11 11:38:21 +02:00
lebaudantoine 32956f495f ⚰️(frontend) remove dead code
Traces of debugging, never been useful.
2025-04-11 11:38:21 +02:00
lebaudantoine 69381a6c4b 🌐(frontend) prepare translation keys for expanded recording types
Restructure translation keys to support a more extensive set
of recording types in the future.
2025-04-11 11:38:21 +02:00
lebaudantoine d537a4449a 🔖(minor) bump release to 0.1.18
Refactoring of transcription ui + SDK
2025-04-08 23:04:24 +02:00
lebaudantoine 50c9304afe (back) remove url-normalize dependency
We have the dependency url-normalize installed but we don't use it in
our codebase.

(from @lunika on docs)
2025-04-07 21:51:54 +02:00
74 changed files with 2720 additions and 364 deletions
+30
View File
@@ -135,6 +135,9 @@ 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
@@ -152,6 +155,33 @@ 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:
+33
View File
@@ -15,6 +15,37 @@ 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: .
@@ -40,6 +71,7 @@ services:
- redis
- nginx
- livekit
- createbuckets
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -73,6 +105,7 @@ services:
- postgresql
- redis
- livekit
- minio
celery:
user: ${DOCKER_USER:-1000}
+10
View File
@@ -12,12 +12,19 @@ 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=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
@@ -42,3 +49,6 @@ LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://localhost:7880
ALLOW_UNREGISTERED_ROOMS=False
# Recording
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
+1 -1
View File
@@ -156,7 +156,7 @@ class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "status"]
fields = ["id", "room", "created_at", "updated_at", "status", "mode"]
read_only_fields = fields
+86 -1
View File
@@ -2,6 +2,7 @@
import uuid
from logging import getLogger
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Q
@@ -20,7 +21,8 @@ from rest_framework import (
status as drf_status,
)
from core import models, utils
from core import enums, models, utils
from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
@@ -544,6 +546,7 @@ class ResourceAccessViewSet(
class RecordingViewSet(
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -617,3 +620,85 @@ class RecordingViewSet(
return drf_response.Response(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
return urlparse(original_url)
def _auth_get_url_params(self, pattern, fragment):
"""
Extracts URL parameters from the given fragment using the specified regex pattern.
Raises PermissionDenied if parameters cannot be extracted.
"""
match = pattern.search(fragment)
try:
return match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a recording's
media file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
parsed_url = self._auth_get_original_url(request)
url_params = self._auth_get_url_params(
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
)
user = request.user
recording_id = url_params["recording_id"]
extension = url_params["extension"]
if extension not in [item.value for item in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
if extension != recording.extension:
raise drf_exceptions.NotFound("No recording found with this extension.")
abilities = recording.get_abilities(user)
if not abilities["retrieve"]:
logger.debug("User '%s' lacks permission for attachment", user.id)
raise drf_exceptions.PermissionDenied()
if not recording.is_saved:
logger.debug("Recording '%s' has not been saved", recording)
raise drf_exceptions.PermissionDenied()
request = utils.generate_s3_authorization_headers(recording.key)
return drf_response.Response("authorized", headers=request.headers, status=200)
+12
View File
@@ -2,9 +2,21 @@
Core application enums declaration
"""
import re
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
+22
View File
@@ -18,6 +18,8 @@ from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
from .recording.enums import FileExtension
logger = getLogger(__name__)
@@ -576,6 +578,26 @@ class Recording(BaseModel):
RecordingStatusChoices.STOPPED,
}
@property
def is_saved(self) -> bool:
"""Check if the recording is in a saved state."""
return self.status == 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}"
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
+10
View File
@@ -0,0 +1,10 @@
"""Enums related to recordings."""
from enum import Enum
class FileExtension(Enum):
"""Enum for file extensions used in recordings."""
OGG = "ogg"
MP4 = "mp4"
@@ -1,8 +1,13 @@
"""Service to notify external services when a new recording is ready."""
import logging
import smtplib
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import requests
@@ -21,10 +26,7 @@ class NotificationService:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
logger.warning(
"Screen recording mode not implemented for recording %s", recording.id
)
return False
return self._notify_user_by_email(recording)
logger.error(
"Unknown recording mode %s for recording %s",
@@ -33,6 +35,59 @@ class NotificationService:
)
return False
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Send an email notification to recording owners when their recording is ready.
The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording.
"""
owner_accesses = models.RecordingAccess.objects.select_related("user").filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
if not owner_accesses:
logger.error("No owner found for recording %s", recording.id)
return False
language = get_language()
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_date": recording.created_at.strftime("%A %d %B %Y"),
"recording_time": recording.created_at.strftime("%H:%M"),
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
}
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,
)
except smtplib.SMTPException as exception:
logger.error("notification to %s was not sent: %s", emails, exception)
return False
return True
@staticmethod
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
@@ -57,10 +112,8 @@ class NotificationService:
logger.error("No owner found for recording %s", recording.id)
return False
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
payload = {
"filename": key,
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
}
@@ -7,6 +7,7 @@ from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from livekit.api.egress_service import EgressService
from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
@@ -89,7 +90,9 @@ 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="mp4")
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.MP4.value
)
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
@@ -120,7 +123,9 @@ 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="ogg")
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.OGG.value
)
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
@@ -0,0 +1,167 @@
"""
Test event notification.
"""
# pylint: disable=E1128,W0621,W0613,W0212
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
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")
owners = [
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
).user,
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
).user,
]
owner_emails = [owner.email for owner in owners]
# 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
mock_send_mail.assert_called_once()
subject, body, sender, recipients = mock_send_mail.call_args[0]
assert subject == "Your recording is ready"
# Verify email contains expected content
required_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
recording.created_at.strftime("%A %d %B %Y"), # Formatted date
recording.created_at.strftime("%H:%M"), # Formatted time
]
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(owner_emails)
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")
owner = factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
).user
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
mock_send_mail.assert_called_once()
assert f"notification to ['{owner.email}'] was not sent" in caplog.text
@@ -22,6 +22,27 @@ 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"],
@@ -58,6 +79,7 @@ def test_api_recordings_list_authenticated_direct(role):
assert results[0] == {
"id": str(recording.id),
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"mode": recording.mode,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
@@ -0,0 +1,284 @@
"""
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_documents_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_documents_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_documents_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_documents_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_documents_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_documents_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_documents_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_documents_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_documents_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_documents_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"
@@ -0,0 +1,153 @@
"""
Test recordings API endpoints in the Meet core app: retrieve.
"""
import pytest
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():
"""A user who is an administrator of a recording should be able to retrieve it."""
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),
"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),
}
def test_api_recording_retrieve_owners():
"""A user who is an owner of a recording should be able to retrieve it."""
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),
"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),
}
@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
@@ -12,7 +12,8 @@ from core.factories import (
UserFactory,
UserRecordingAccessFactory,
)
from core.models import Recording, RecordingStatusChoices
from core.models import Recording, RecordingModeChoices, RecordingStatusChoices
from core.recording.enums import FileExtension
pytestmark = pytest.mark.django_db
@@ -216,3 +217,71 @@ 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 status."""
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
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
+32
View File
@@ -11,7 +11,9 @@ from typing import Optional
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
import botocore
from livekit.api import AccessToken, VideoGrants
@@ -110,3 +112,33 @@ def generate_livekit_config(
room=room_id, user=user, username=username, color=color
),
}
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our recording' metadata
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
+319 -59
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"POT-Creation-Date: 2025-04-14 19: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"
@@ -17,28 +17,32 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:31
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/admin.py:33
#: core/admin.py:39
msgid "Permissions"
msgstr ""
#: core/admin.py:45
#: core/admin.py:51
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:128
msgid "Markdown Body"
#: core/api/serializers.py:60
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
#: core/authentication.py:71
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr ""
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr ""
#: core/models.py:27
@@ -53,156 +57,412 @@ msgstr ""
msgid "Owner"
msgstr ""
#: core/models.py:41
msgid "id"
#: core/models.py:45
msgid "Initiated"
msgstr ""
#: core/models.py:42
msgid "primary key for the record as UUID"
#: core/models.py:46
msgid "Active"
msgstr ""
#: core/models.py:47
msgid "Stopped"
msgstr ""
#: core/models.py:48
msgid "created on"
msgid "Saved"
msgstr ""
#: core/models.py:49
msgid "Aborted"
msgstr ""
#: core/models.py:50
msgid "Failed to Start"
msgstr ""
#: core/models.py:51
msgid "Failed to Stop"
msgstr ""
#: core/models.py:52
msgid "Notification succeeded"
msgstr ""
#: core/models.py:79
msgid "SCREEN_RECORDING"
msgstr ""
#: core/models.py:80
msgid "TRANSCRIPT"
msgstr ""
#: core/models.py:86
msgid "Public Access"
msgstr ""
#: core/models.py:87
msgid "Trusted Access"
msgstr ""
#: core/models.py:88
msgid "Restricted Access"
msgstr ""
#: core/models.py:100
msgid "id"
msgstr ""
#: core/models.py:101
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:107
msgid "created on"
msgstr ""
#: core/models.py:108
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:54
#: core/models.py:113
msgid "updated on"
msgstr ""
#: core/models.py:55
#: core/models.py:114
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:75
#: core/models.py:134
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:81
#: core/models.py:140
msgid "sub"
msgstr ""
#: core/models.py:83
#: core/models.py:142
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:91
#: core/models.py:150
msgid "identity email address"
msgstr ""
#: core/models.py:96
#: core/models.py:155
msgid "admin email address"
msgstr ""
#: core/models.py:103
#: core/models.py:157
msgid "full name"
msgstr ""
#: core/models.py:159
msgid "short name"
msgstr ""
#: core/models.py:165
msgid "language"
msgstr ""
#: core/models.py:104
#: core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:110
#: core/models.py:172
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:113
#: core/models.py:175
msgid "device"
msgstr ""
#: core/models.py:115
#: core/models.py:177
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:118
#: core/models.py:180
msgid "staff status"
msgstr ""
#: core/models.py:120
#: core/models.py:182
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:123
#: core/models.py:185
msgid "active"
msgstr ""
#: core/models.py:126
#: core/models.py:188
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:138
#: core/models.py:201
msgid "user"
msgstr ""
#: core/models.py:139
#: core/models.py:202
msgid "users"
msgstr ""
#: core/models.py:161
msgid "title"
#: core/models.py:261
msgid "Resource"
msgstr ""
#: core/models.py:162
msgid "description"
#: core/models.py:262
msgid "Resources"
msgstr ""
#: core/models.py:163
msgid "code"
#: core/models.py:316
msgid "Resource access"
msgstr ""
#: core/models.py:164
msgid "css"
#: core/models.py:317
msgid "Resource accesses"
msgstr ""
#: core/models.py:166
msgid "public"
#: core/models.py:323
msgid "Resource access with this User and Resource already exists."
msgstr ""
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
#: core/models.py:379
msgid "Visio room configuration"
msgstr ""
#: core/models.py:174
msgid "Template"
#: core/models.py:380
msgid "Values for Visio parameters to configure the room."
msgstr ""
#: core/models.py:175
msgid "Templates"
#: core/models.py:386 core/models.py:506
msgid "Room"
msgstr ""
#: core/models.py:256
msgid "Template/user relation"
#: core/models.py:387
msgid "Rooms"
msgstr ""
#: core/models.py:257
msgid "Template/user relations"
#: core/models.py:517
msgid "Worker ID"
msgstr ""
#: core/models.py:263
msgid "This user is already in this template."
#: core/models.py:519
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
#: core/models.py:269
msgid "This team is already in this template."
#: core/models.py:527
msgid "Recording mode"
msgstr ""
#: core/models.py:275
#: core/models.py:528
msgid "Defines the mode of recording being called."
msgstr ""
#: core/models.py:534
msgid "Recording"
msgstr ""
#: core/models.py:535
msgid "Recordings"
msgstr ""
#: core/models.py:592
msgid "Recording/user relation"
msgstr ""
#: core/models.py:593
msgid "Recording/user relations"
msgstr ""
#: core/models.py:599
msgid "This user is already in this recording."
msgstr ""
#: core/models.py:605
msgid "This team is already in this recording."
msgstr ""
#: core/models.py:611
msgid "Either user or team must be set, not both."
msgstr ""
#: meet/settings.py:134
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr ""
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr ""
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr ""
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr ""
#: core/recording/event/notification.py:81
msgid "Your recording is ready"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Meet</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
"We are delighted to welcome you to our community on Meet, your new companion "
"to collaborate on documents efficiently, intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:15
msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Meet, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create documents."
msgstr ""
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your document or community in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:249
#: core/templates/mail/text/invitation.txt:20
msgid "Visit Meet"
msgstr ""
#: core/templates/mail/html/invitation.html:258
#: core/templates/mail/text/invitation.txt:22
msgid ""
"We are confident that Meet will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:23
msgid ""
"Feel free to explore all the features of the application and share your "
"feedback and suggestions with us. Your feedback is valuable to us and will "
"enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:268
#: core/templates/mail/text/invitation.txt:24
msgid ""
"Once again, welcome aboard! We are eager to accompany you on you "
"collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:275
#: core/templates/mail/text/invitation.txt:26
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:276
#: core/templates/mail/text/invitation.txt:28
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr ""
#: 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 ""
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr ""
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \\"
msgstr ""
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \\"
msgstr ""
#: 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 ""
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr ""
#: 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 ""
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Meet"
msgstr ""
#: meet/settings.py:161
msgid "English"
msgstr ""
#: meet/settings.py:135
#: meet/settings.py:162
msgid "French"
msgstr ""
+319 -59
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
"POT-Creation-Date: 2025-04-14 19: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"
@@ -17,28 +17,32 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:31
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: core/admin.py:33
#: core/admin.py:39
msgid "Permissions"
msgstr ""
#: core/admin.py:45
#: core/admin.py:51
msgid "Important dates"
msgstr ""
#: core/api/serializers.py:128
msgid "Markdown Body"
#: core/api/serializers.py:60
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
#: core/authentication.py:71
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication.py:91
msgid "Claims contained no recognizable user identification"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr ""
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr ""
#: core/models.py:27
@@ -53,156 +57,412 @@ msgstr ""
msgid "Owner"
msgstr ""
#: core/models.py:41
msgid "id"
#: core/models.py:45
msgid "Initiated"
msgstr ""
#: core/models.py:42
msgid "primary key for the record as UUID"
#: core/models.py:46
msgid "Active"
msgstr ""
#: core/models.py:47
msgid "Stopped"
msgstr ""
#: core/models.py:48
msgid "created on"
msgid "Saved"
msgstr ""
#: core/models.py:49
msgid "Aborted"
msgstr ""
#: core/models.py:50
msgid "Failed to Start"
msgstr ""
#: core/models.py:51
msgid "Failed to Stop"
msgstr ""
#: core/models.py:52
msgid "Notification succeeded"
msgstr ""
#: core/models.py:79
msgid "SCREEN_RECORDING"
msgstr ""
#: core/models.py:80
msgid "TRANSCRIPT"
msgstr ""
#: core/models.py:86
msgid "Public Access"
msgstr ""
#: core/models.py:87
msgid "Trusted Access"
msgstr ""
#: core/models.py:88
msgid "Restricted Access"
msgstr ""
#: core/models.py:100
msgid "id"
msgstr ""
#: core/models.py:101
msgid "primary key for the record as UUID"
msgstr ""
#: core/models.py:107
msgid "created on"
msgstr ""
#: core/models.py:108
msgid "date and time at which a record was created"
msgstr ""
#: core/models.py:54
#: core/models.py:113
msgid "updated on"
msgstr ""
#: core/models.py:55
#: core/models.py:114
msgid "date and time at which a record was last updated"
msgstr ""
#: core/models.py:75
#: core/models.py:134
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
#: core/models.py:81
#: core/models.py:140
msgid "sub"
msgstr ""
#: core/models.py:83
#: core/models.py:142
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
#: core/models.py:91
#: core/models.py:150
msgid "identity email address"
msgstr ""
#: core/models.py:96
#: core/models.py:155
msgid "admin email address"
msgstr ""
#: core/models.py:103
#: core/models.py:157
msgid "full name"
msgstr ""
#: core/models.py:159
msgid "short name"
msgstr ""
#: core/models.py:165
msgid "language"
msgstr ""
#: core/models.py:104
#: core/models.py:166
msgid "The language in which the user wants to see the interface."
msgstr ""
#: core/models.py:110
#: core/models.py:172
msgid "The timezone in which the user wants to see times."
msgstr ""
#: core/models.py:113
#: core/models.py:175
msgid "device"
msgstr ""
#: core/models.py:115
#: core/models.py:177
msgid "Whether the user is a device or a real user."
msgstr ""
#: core/models.py:118
#: core/models.py:180
msgid "staff status"
msgstr ""
#: core/models.py:120
#: core/models.py:182
msgid "Whether the user can log into this admin site."
msgstr ""
#: core/models.py:123
#: core/models.py:185
msgid "active"
msgstr ""
#: core/models.py:126
#: core/models.py:188
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
#: core/models.py:138
#: core/models.py:201
msgid "user"
msgstr ""
#: core/models.py:139
#: core/models.py:202
msgid "users"
msgstr ""
#: core/models.py:161
msgid "title"
#: core/models.py:261
msgid "Resource"
msgstr ""
#: core/models.py:162
msgid "description"
#: core/models.py:262
msgid "Resources"
msgstr ""
#: core/models.py:163
msgid "code"
#: core/models.py:316
msgid "Resource access"
msgstr ""
#: core/models.py:164
msgid "css"
#: core/models.py:317
msgid "Resource accesses"
msgstr ""
#: core/models.py:166
msgid "public"
#: core/models.py:323
msgid "Resource access with this User and Resource already exists."
msgstr ""
#: core/models.py:168
msgid "Whether this template is public for anyone to use."
#: core/models.py:379
msgid "Visio room configuration"
msgstr ""
#: core/models.py:174
msgid "Template"
#: core/models.py:380
msgid "Values for Visio parameters to configure the room."
msgstr ""
#: core/models.py:175
msgid "Templates"
#: core/models.py:386 core/models.py:506
msgid "Room"
msgstr ""
#: core/models.py:256
msgid "Template/user relation"
#: core/models.py:387
msgid "Rooms"
msgstr ""
#: core/models.py:257
msgid "Template/user relations"
#: core/models.py:517
msgid "Worker ID"
msgstr ""
#: core/models.py:263
msgid "This user is already in this template."
#: core/models.py:519
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
#: core/models.py:269
msgid "This team is already in this template."
#: core/models.py:527
msgid "Recording mode"
msgstr ""
#: core/models.py:275
#: core/models.py:528
msgid "Defines the mode of recording being called."
msgstr ""
#: core/models.py:534
msgid "Recording"
msgstr ""
#: core/models.py:535
msgid "Recordings"
msgstr ""
#: core/models.py:592
msgid "Recording/user relation"
msgstr ""
#: core/models.py:593
msgid "Recording/user relations"
msgstr ""
#: core/models.py:599
msgid "This user is already in this recording."
msgstr ""
#: core/models.py:605
msgid "This team is already in this recording."
msgstr ""
#: core/models.py:611
msgid "Either user or team must be set, not both."
msgstr ""
#: meet/settings.py:134
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr ""
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr ""
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr ""
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr ""
#: core/recording/event/notification.py:81
msgid "Your recording is ready"
msgstr ""
#: core/templates/mail/html/invitation.html:160
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Meet</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:216
#: core/templates/mail/text/invitation.txt:12
msgid "Logo"
msgstr ""
#: core/templates/mail/html/invitation.html:226
#: core/templates/mail/text/invitation.txt:14
msgid ""
"We are delighted to welcome you to our community on Meet, your new companion "
"to collaborate on documents efficiently, intuitively, and securely."
msgstr ""
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/text/invitation.txt:15
msgid ""
"Our application is designed to help you organize, collaborate, and manage "
"permissions."
msgstr ""
#: core/templates/mail/html/invitation.html:236
#: core/templates/mail/text/invitation.txt:16
msgid "With Meet, you will be able to:"
msgstr ""
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create documents."
msgstr ""
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your document or community in just a few clicks."
msgstr ""
#: core/templates/mail/html/invitation.html:249
#: core/templates/mail/text/invitation.txt:20
msgid "Visit Meet"
msgstr ""
#: core/templates/mail/html/invitation.html:258
#: core/templates/mail/text/invitation.txt:22
msgid ""
"We are confident that Meet will help you increase efficiency and "
"productivity while strengthening the bond among members."
msgstr ""
#: core/templates/mail/html/invitation.html:263
#: core/templates/mail/text/invitation.txt:23
msgid ""
"Feel free to explore all the features of the application and share your "
"feedback and suggestions with us. Your feedback is valuable to us and will "
"enable us to continually improve our service."
msgstr ""
#: core/templates/mail/html/invitation.html:268
#: core/templates/mail/text/invitation.txt:24
msgid ""
"Once again, welcome aboard! We are eager to accompany you on you "
"collaboration adventure."
msgstr ""
#: core/templates/mail/html/invitation.html:275
#: core/templates/mail/text/invitation.txt:26
msgid "Sincerely,"
msgstr ""
#: core/templates/mail/html/invitation.html:276
#: core/templates/mail/text/invitation.txt:28
msgid "The La Suite Numérique Team"
msgstr ""
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr ""
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr ""
#: 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 ""
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr ""
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \\"
msgstr ""
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \\"
msgstr ""
#: 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 ""
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr ""
#: 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 ""
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr ""
#: core/templates/mail/text/invitation.txt:8
msgid "Welcome to Meet"
msgstr ""
#: meet/settings.py:161
msgid "English"
msgstr ""
#: meet/settings.py:135
#: meet/settings.py:162
msgid "French"
msgstr ""
+10
View File
@@ -307,6 +307,9 @@ 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
),
}
# Mail
@@ -318,6 +321,10 @@ class Base(Configuration):
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_FROM = values.Value("from@example.com")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None)
EMAIL_LOGO_IMG = values.Value(None)
EMAIL_DOMAIN = values.Value(None)
AUTH_USER_MODEL = "core.User"
@@ -481,6 +488,9 @@ class Base(Configuration):
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="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
+2 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.17"
version = "0.1.18"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.37.18",
"boto3==1.37.24",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.4.0",
@@ -53,7 +53,6 @@ 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==0.8.2",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.17",
"version": "0.1.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.17",
"version": "0.1.18",
"dependencies": {
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.17",
"version": "0.1.18",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+2 -1
View File
@@ -1,7 +1,7 @@
import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { RecordingMode } from '@/features/recording'
export interface ApiConfig {
analytics?: {
@@ -12,6 +12,7 @@ export interface ApiConfig {
id: string
}
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
recording?: {
is_enabled?: boolean
available_modes?: RecordingMode[]
Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

@@ -0,0 +1,5 @@
export enum FeatureFlags {
Transcript = 'transcription-summary',
ScreenRecording = 'screen-recording',
faceLandmarks = 'face-landmarks',
}
+16 -2
View File
@@ -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 } from 'react'
import { useEffect, useMemo } from 'react'
import {
startAnalyticsSession,
terminateAnalyticsSession,
@@ -12,6 +12,7 @@ import {
terminateSupportSession,
} from '@/features/support/hooks/useSupport'
import { logoutUrl } from '../utils/logoutUrl'
import { useConfig } from '@/api/useConfig'
/**
* returns info about currently logged-in user
@@ -23,11 +24,24 @@ export const useUser = (
fetchUserOptions?: Parameters<typeof fetchUser>[0]
} = {}
) => {
const { data, isLoading } = useConfig()
const options = useMemo(() => {
if (!data || data?.is_silent_login_enabled !== true) {
return {
...opts,
attemptSilent: false,
}
}
return opts.fetchUserOptions
}, [data, opts])
const query = useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: [keys.user],
queryFn: () => fetchUser(opts.fetchUserOptions),
queryFn: () => fetchUser(options),
staleTime: Infinity,
enabled: !isLoading,
})
useEffect(() => {
@@ -90,6 +90,8 @@ export const MainNotificationToast = () => {
break
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
toastQueue.add(
{
participant,
@@ -8,4 +8,6 @@ export enum NotificationType {
ParticipantWaiting = 'participantWaiting',
TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped',
ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped',
}
@@ -48,7 +48,7 @@ export function Toast({ state, ...props }: ToastProps) {
return (
<StyledToastContainer {...toastProps} ref={ref}>
<StyledToast>
<div {...contentProps}>{props.toast.content?.message} machine a</div>
<div {...contentProps}>{props.toast.content?.message}</div>
<Button square size="sm" invisible {...closeButtonProps}>
<RiCloseLine color="white" />
</Button>
@@ -1,19 +1,34 @@
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { useMemo, useRef } from 'react'
import { StyledToastContainer, ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { NotificationType } from '../NotificationType'
export function ToastTranscript({ state, ...props }: ToastProps) {
export function ToastAnyRecording({ state, ...props }: ToastProps) {
const { t } = useTranslation('notifications')
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant
const type = props.toast.content.type
const key = `recording${type == NotificationType.TranscriptionStarted ? 'Started' : 'Stopped'}`
const key = useMemo(() => {
switch (type) {
case NotificationType.TranscriptionStarted:
return 'transcript.started'
case NotificationType.TranscriptionStopped:
return 'transcript.stopped'
case NotificationType.ScreenRecordingStarted:
return 'screenRecording.started'
case NotificationType.ScreenRecordingStopped:
return 'screenRecording.stopped'
default:
return
}
}, [type])
if (!key) return
return (
<StyledToastContainer {...toastProps} ref={ref}>
@@ -9,7 +9,7 @@ import { ToastRaised } from './ToastRaised'
import { ToastMuted } from './ToastMuted'
import { ToastMessageReceived } from './ToastMessageReceived'
import { ToastLowerHand } from './ToastLowerHand'
import { ToastTranscript } from './ToastTranscript'
import { ToastAnyRecording } from './ToastAnyRecording'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -39,7 +39,9 @@ const renderToast = (
case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped:
return <ToastTranscript key={toast.key} toast={toast} state={state} />
case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
default:
return <Toast key={toast.key} toast={toast} state={state} />
@@ -0,0 +1,37 @@
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 }
}
@@ -0,0 +1,2 @@
export { useNotifyParticipants } from './hooks/useNotifyParticipants'
export { NotificationType } from './NotificationType'
@@ -1,12 +1,8 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from './ApiRoom'
export enum RecordingMode {
Transcript = 'transcript',
ScreenRecording = 'screen_recording',
}
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { RecordingMode } from '../types'
export interface StartRecordingParams {
id: string
@@ -1,7 +1,7 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { ApiRoom } from './ApiRoom'
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
export interface StopRecordingParams {
id: string
@@ -8,20 +8,22 @@ import { Text } from '@/primitives'
import { RemoteParticipant, RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType'
import { TranscriptionStatus, transcriptionStore } from '@/stores/transcription'
import { RecordingStatus, recordingStore } from '@/stores/recording'
export const TranscriptStateToast = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'recording.transcript' })
export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'recordingBadge',
})
const room = useRoomContext()
const transcriptionSnap = useSnapshot(transcriptionStore)
const recordingSnap = useSnapshot(recordingStore)
useEffect(() => {
if (room.isRecording) {
transcriptionStore.status = TranscriptionStatus.STARTED
if (room.isRecording && recordingSnap.status == RecordingStatus.STOPPED) {
recordingStore.status = RecordingStatus.ANY_STARTED
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [room.isRecording])
useEffect(() => {
const handleDataReceived = (
@@ -34,10 +36,16 @@ export const TranscriptStateToast = () => {
switch (notification.type) {
case NotificationType.TranscriptionStarted:
transcriptionStore.status = TranscriptionStatus.STARTING
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
break
case NotificationType.TranscriptionStopped:
transcriptionStore.status = TranscriptionStatus.STOPPING
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.ScreenRecordingStarted:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
break
case NotificationType.ScreenRecordingStopped:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
default:
return
@@ -45,9 +53,17 @@ export const TranscriptStateToast = () => {
}
const handleRecordingStatusChanged = (status: boolean) => {
transcriptionStore.status = status
? TranscriptionStatus.STARTED
: TranscriptionStatus.STOPPED
if (!status) {
recordingStore.status = RecordingStatus.STOPPED
} else if (recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING) {
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTED
} else if (
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING
) {
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTED
} else {
recordingStore.status = RecordingStatus.ANY_STARTED
}
}
room.on(RoomEvent.DataReceived, handleDataReceived)
@@ -57,20 +73,30 @@ export const TranscriptStateToast = () => {
room.off(RoomEvent.DataReceived, handleDataReceived)
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
}, [room, recordingSnap])
const key = useMemo(() => {
switch (transcriptionSnap.status) {
case TranscriptionStatus.STOPPING:
return 'stopping'
case TranscriptionStatus.STARTING:
return 'starting'
switch (recordingSnap.status) {
case RecordingStatus.TRANSCRIPT_STARTED:
return 'transcript.started'
case RecordingStatus.TRANSCRIPT_STOPPING:
return 'transcript.stopping'
case RecordingStatus.TRANSCRIPT_STARTING:
return 'transcript.starting'
case RecordingStatus.SCREEN_RECORDING_STARTED:
return 'screenRecording.started'
case RecordingStatus.SCREEN_RECORDING_STOPPING:
return 'screenRecording.stopping'
case RecordingStatus.SCREEN_RECORDING_STARTING:
return 'screenRecording.starting'
case RecordingStatus.ANY_STARTED:
return 'any.started'
default:
return 'started'
return
}
}, [transcriptionSnap])
}, [recordingSnap])
if (transcriptionSnap.status == TranscriptionStatus.STOPPED) return
if (!key) return
return (
<div
@@ -1,43 +1,53 @@
import { A, Button, Div, LinkButton, Text } from '@/primitives'
import { A, Button, Div, H, Text } from '@/primitives'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
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'
useStopRecording,
} from '@/features/recording'
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 '@/features/recording'
import {
useNotifyParticipants,
NotificationType,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
import {
TranscriptionStatus,
transcriptionStore,
} from '@/stores/transcription.ts'
import { useHasTranscriptAccess } from '../hooks/useHasTranscriptAccess'
import {
BETA_USERS_FORM_URL,
CRISP_HELP_ARTICLE_TRANSCRIPT,
} from '@/utils/constants'
export const Transcript = () => {
export const ScreenRecordingSidePanel = () => {
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const recordingSnap = useSnapshot(recordingStore)
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
const { notifyParticipants } = useNotifyParticipants()
const hasTranscriptAccess = useHasTranscriptAccess()
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const transcriptionSnap = useSnapshot(transcriptionStore)
const statuses = useMemo(() => {
return {
isAnotherModeStarted:
recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
isStarted:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
isStopping:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STOPPING,
}
}, [recordingSnap])
const room = useRoomContext()
const isRecordingTransitioning = useIsRecordingTransitioning()
useEffect(() => {
const handleRecordingStatusChanged = () => {
@@ -49,18 +59,7 @@ export const Transcript = () => {
}
}, [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 () => {
const handleScreenRecording = async () => {
if (!roomId) {
console.warn('No room ID found')
return
@@ -69,12 +68,20 @@ export const Transcript = () => {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
await notifyParticipant(NotificationType.TranscriptionStopped)
transcriptionStore.status = TranscriptionStatus.STOPPING
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
await notifyParticipants({
type: NotificationType.ScreenRecordingStopped,
})
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
await notifyParticipant(NotificationType.TranscriptionStarted)
transcriptionStore.status = TranscriptionStatus.STARTING
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)
@@ -84,10 +91,8 @@ export const Transcript = () => {
const isDisabled = useMemo(
() =>
isLoading ||
transcriptionSnap.status === TranscriptionStatus.STARTING ||
transcriptionSnap.status === TranscriptionStatus.STOPPING,
[isLoading, transcriptionSnap]
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
[isLoading, isRecordingTransitioning, statuses]
)
return (
@@ -100,16 +105,19 @@ export const Transcript = () => {
alignItems="center"
>
<img
src={thirdSlide}
src={fourthSlide}
alt={''}
className={css({
minHeight: '309px',
marginBottom: '1rem',
})}
/>
{!hasTranscriptAccess ? (
{statuses.isStarted ? (
<>
<Text>{t('beta.heading')}</Text>
<H lvl={3} margin={false}>
{t('stop.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
@@ -120,50 +128,44 @@ export const Transcript = () => {
marginTop: '0.25rem',
})}
>
{t('beta.body')}{' '}
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
{t('start.linkMore')}
</A>
{t('stop.body')}
</Text>
<LinkButton
<Button
isDisabled={isDisabled}
onPress={() => handleScreenRecording()}
data-attr="stop-screen-recording"
size="sm"
variant="tertiary"
href={BETA_USERS_FORM_URL}
target="_blank"
>
{t('beta.button')}
</LinkButton>
{t('stop.button')}
</Button>
</>
) : (
<>
{room.isRecording ? (
{statuses.isStopping ? (
<>
<Text>{t('stop.heading')}</Text>
<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('stop.body')}
{t('stopping.body')}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="stop-transcript"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
<Text>{t('start.heading')}</Text>
<H lvl={3} margin={false}>
{t('start.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
@@ -176,14 +178,14 @@ export const Transcript = () => {
})}
>
{t('start.body')} <br />{' '}
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
<A href={CRISP_HELP_ARTICLE_RECORDING} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="start-transcript"
onPress={() => handleScreenRecording()}
data-attr="start-screen-recording"
size="sm"
variant="tertiary"
>
@@ -0,0 +1,235 @@
import { A, Button, Div, H, LinkButton, Text } from '@/primitives'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useHasRecordingAccess,
useIsRecordingTransitioning,
useStartRecording,
useStopRecording,
} from '../index'
import { useEffect, useMemo, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import {
BETA_USERS_FORM_URL,
CRISP_HELP_ARTICLE_TRANSCRIPT,
} from '@/utils/constants'
import { FeatureFlags } from '@/features/analytics/enums'
import {
NotificationType,
useNotifyParticipants,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
export const TranscriptSidePanel = () => {
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const recordingSnap = useSnapshot(recordingStore)
const { notifyParticipants } = useNotifyParticipants()
const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript,
FeatureFlags.Transcript
)
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const statuses = useMemo(() => {
return {
isAnotherModeStarted:
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
isStarted: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
isStopping: recordingSnap.status == RecordingStatus.TRANSCRIPT_STOPPING,
}
}, [recordingSnap])
const isRecordingTransitioning = useIsRecordingTransitioning()
const room = useRoomContext()
useEffect(() => {
const handleRecordingStatusChanged = () => {
setIsLoading(false)
}
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
return () => {
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
}
}, [room])
const handleTranscript = async () => {
if (!roomId) {
console.warn('No room ID found')
return
}
try {
setIsLoading(true)
if (room.isRecording) {
await stopRecordingRoom({ id: roomId })
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
await notifyParticipants({
type: NotificationType.TranscriptionStopped,
})
} else {
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
await notifyParticipants({
type: NotificationType.TranscriptionStarted,
})
posthog.capture('transcript-started', {})
}
} catch (error) {
console.error('Failed to handle transcript:', error)
setIsLoading(false)
}
}
const isDisabled = useMemo(
() =>
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
[isLoading, isRecordingTransitioning, statuses]
)
return (
<Div
display="flex"
overflowY="scroll"
padding="0 1.5rem"
flexGrow={1}
flexDirection="column"
alignItems="center"
>
<img
src={thirdSlide}
alt={''}
className={css({
minHeight: '309px',
marginBottom: '1rem',
})}
/>
{!hasTranscriptAccess ? (
<>
<Text>{t('beta.heading')}</Text>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('beta.body')}{' '}
<A href={CRISP_HELP_ARTICLE_TRANSCRIPT} target="_blank">
{t('start.linkMore')}
</A>
</Text>
<LinkButton
size="sm"
variant="tertiary"
href={BETA_USERS_FORM_URL}
target="_blank"
>
{t('beta.button')}
</LinkButton>
</>
) : (
<>
{statuses.isStarted ? (
<>
<H lvl={3} margin={false}>
{t('stop.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stop.body')}
</Text>
<Button
isDisabled={isDisabled}
onPress={() => handleTranscript()}
data-attr="stop-transcript"
size="sm"
variant="tertiary"
>
{t('stop.button')}
</Button>
</>
) : (
<>
{statuses.isStopping ? (
<>
<H lvl={3} margin={false}>
{t('stopping.heading')}
</H>
<Text
variant="note"
wrap={'pretty'}
centered
className={css({
textStyle: 'sm',
maxWidth: '90%',
marginBottom: '2.5rem',
marginTop: '0.25rem',
})}
>
{t('stopping.body')}
</Text>
</>
) : (
<>
<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"
>
{t('start.button')}
</Button>
</>
)}
</>
)}
</>
)}
</Div>
)
}
@@ -0,0 +1,22 @@
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
)
}
@@ -0,0 +1,11 @@
import { RecordingMode } from '../types'
import { useConfig } from '@/api/useConfig'
export const useIsRecordingModeEnabled = (mode: RecordingMode) => {
const { data } = useConfig()
return (
data?.recording?.is_enabled &&
data?.recording?.available_modes?.includes(mode)
)
}
@@ -0,0 +1,15 @@
import { useSnapshot } from 'valtio'
import { RecordingStatus, recordingStore } from '@/stores/recording'
export const useIsRecordingTransitioning = () => {
const recordingSnap = useSnapshot(recordingStore)
const transitionalStates = [
RecordingStatus.TRANSCRIPT_STARTING,
RecordingStatus.TRANSCRIPT_STOPPING,
RecordingStatus.SCREEN_RECORDING_STARTING,
RecordingStatus.SCREEN_RECORDING_STOPPING,
]
return transitionalStates.includes(recordingSnap.status)
}
@@ -0,0 +1,14 @@
// hooks
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
export { useIsRecordingTransitioning } from './hooks/useIsRecordingTransitioning'
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
// api
export { useStartRecording } from './api/startRecording'
export { useStopRecording } from './api/stopRecording'
export { RecordingMode } from './types'
// components
export { RecordingStateToast } from './components/RecordingStateToast'
export { TranscriptSidePanel } from './components/TranscriptSidePanel'
export { ScreenRecordingSidePanel } from './components/ScreenRecordingSidePanel'
@@ -0,0 +1,4 @@
export enum RecordingMode {
Transcript = 'transcript',
ScreenRecording = 'screen_recording',
}
@@ -3,25 +3,15 @@ import Source = Track.Source
import { fetchServerApi } from './fetchServerApi'
import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useRoomContext } from '@livekit/components-react'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import {
useNotifyParticipants,
NotificationType,
} from '@/features/notifications'
export const useMuteParticipant = () => {
const data = useRoomData()
const room = useRoomContext()
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 { notifyParticipants } = useNotifyParticipants()
const muteParticipant = async (participant: Participant) => {
if (!data || !data?.livekit) {
@@ -53,7 +43,10 @@ export const useMuteParticipant = () => {
}
)
await notifyParticipant(participant)
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
return response
} catch (error) {
@@ -4,10 +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 } from '@remixicon/react'
import { useIsTranscriptEnabled } from '../hooks/useIsTranscriptEnabled'
import { useSidePanel } from '../hooks/useSidePanel'
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import {
useIsRecordingModeEnabled,
RecordingMode,
useHasRecordingAccess,
} from '@/features/recording'
import {
TranscriptSidePanel,
ScreenRecordingSidePanel,
} from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums'
export interface ToolsButtonProps {
icon: ReactNode
@@ -68,12 +76,25 @@ const ToolButton = ({
}
export const Tools = () => {
const { openTranscript, isTranscriptOpen } = useSidePanel()
const { openTranscript, openScreenRecording, activeSubPanelId } =
useSidePanel()
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
const isTranscriptEnabled = useIsTranscriptEnabled()
const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript
)
if (isTranscriptOpen && isTranscriptEnabled) {
return <Transcript />
const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording
)
switch (activeSubPanelId) {
case SubPanelId.TRANSCRIPT:
return <TranscriptSidePanel />
case SubPanelId.SCREEN_RECORDING:
return <ScreenRecordingSidePanel />
default:
break
}
return (
@@ -108,6 +129,14 @@ export const Tools = () => {
onPress={() => openTranscript()}
/>
)}
{hasScreenRecordingAccess && (
<ToolButton
icon={<RiLiveFill size={24} color="white" />}
title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()}
/>
)}
</Div>
)
}
@@ -1,8 +1,9 @@
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('face-landmarks')
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
return featureEnabled || !isAnalyticsEnabled
@@ -1,17 +0,0 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { useIsTranscriptEnabled } from './useIsTranscriptEnabled'
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
export const useHasTranscriptAccess = () => {
const featureEnabled = useFeatureFlagEnabled('transcription-summary')
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const isTranscriptEnabled = useIsTranscriptEnabled()
const isAdminOrOwner = useIsAdminOrOwner()
return (
(featureEnabled || !isAnalyticsEnabled) &&
isAdminOrOwner &&
isTranscriptEnabled
)
}
@@ -1,11 +0,0 @@
import { RecordingMode } from '@/features/rooms/api/startRecording'
import { useConfig } from '@/api/useConfig'
export const useIsTranscriptEnabled = () => {
const { data } = useConfig()
return (
data?.recording?.is_enabled &&
data?.recording?.available_modes?.includes(RecordingMode.Transcript)
)
}
@@ -11,6 +11,7 @@ export enum PanelId {
export enum SubPanelId {
TRANSCRIPT = 'transcript',
SCREEN_RECORDING = 'screenRecording',
}
export const useSidePanel = () => {
@@ -24,6 +25,7 @@ export const useSidePanel = () => {
const isToolsOpen = activePanelId == PanelId.TOOLS
const isAdminOpen = activePanelId == PanelId.ADMIN
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
const isSidePanelOpen = !!activePanelId
const isSubPanelOpen = !!activeSubPanelId
@@ -57,6 +59,11 @@ export const useSidePanel = () => {
layoutStore.activePanelId = PanelId.TOOLS
}
const openScreenRecording = () => {
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
layoutStore.activePanelId = PanelId.TOOLS
}
return {
activePanelId,
activeSubPanelId,
@@ -66,6 +73,7 @@ export const useSidePanel = () => {
toggleTools,
toggleAdmin,
openTranscript,
openScreenRecording,
isSubPanelOpen,
isChatOpen,
isParticipantsOpen,
@@ -74,5 +82,6 @@ export const useSidePanel = () => {
isToolsOpen,
isAdminOpen,
isTranscriptOpen,
isScreenRecordingOpen,
}
}
@@ -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 { TranscriptStateToast } from '../components/TranscriptStateToast'
import { RecordingStateToast } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
const LayoutWrapper = styled(
@@ -231,7 +231,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
)}
<RoomAudioRenderer />
<ConnectionStateToast />
<TranscriptStateToast />
<RecordingStateToast />
</div>
)
}
@@ -22,6 +22,12 @@
"open": "",
"accept": ""
},
"recordingStarted": "",
"recordingStopped": ""
"transcript": {
"started": "",
"stopped": ""
},
"screenRecording": {
"started": "",
"stopped": ""
}
}
+36 -1
View File
@@ -169,6 +169,7 @@
"effects": "",
"chat": "",
"transcript": "",
"screenRecording": "",
"admin": "",
"tools": ""
},
@@ -177,6 +178,7 @@
"effects": "",
"chat": "",
"transcript": "",
"screenRecording": "",
"admin": "",
"tools": ""
},
@@ -192,6 +194,10 @@
"transcript": {
"title": "",
"body": ""
},
"screenRecording": {
"title": "",
"body": ""
}
}
},
@@ -207,12 +213,33 @@
"body": "",
"button": ""
},
"stopping": {
"heading": "",
"body": ""
},
"beta": {
"heading": "",
"body": "",
"button": ""
}
},
"screenRecording": {
"start": {
"heading": "",
"body": "",
"button": "",
"linkMore": ""
},
"stopping": {
"heading": "",
"body": ""
},
"stop": {
"heading": "",
"body": "",
"button": ""
}
},
"admin": {
"description": "",
"access": {
@@ -292,11 +319,19 @@
}
}
},
"recording": {
"recordingBadge": {
"transcript": {
"started": "",
"starting": "",
"stopping": ""
},
"screenRecording": {
"started": "",
"starting": "",
"stopping": ""
},
"any": {
"started": ""
}
},
"participantTileFocus": {
@@ -22,6 +22,12 @@
"open": "Open",
"accept": "Accept"
},
"recordingStarted": "{{name}} started the meeting transcription.",
"recordingStopped": "{{name}} stopped the meeting transcription."
"transcript": {
"started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription."
},
"screenRecording": {
"started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording."
}
}
+38 -3
View File
@@ -168,6 +168,7 @@
"effects": "Effects",
"chat": "Messages in the chat",
"transcript": "Transcription",
"screenRecording": "Recording",
"admin": "Admin settings",
"tools": "More tools"
},
@@ -175,7 +176,8 @@
"participants": "participants",
"effects": "effects",
"chat": "messages",
"transcript": "Transcription",
"transcript": "transcription",
"screenRecording": "recording",
"admin": "admin settings",
"tools": "more tools"
},
@@ -190,7 +192,11 @@
"tools": {
"transcript": {
"title": "Transcription",
"body": "Transcribe your meeting for later"
"body": "Keep a written record of your meeting."
},
"screenRecording": {
"title": "Recording",
"body": "Record your meeting to watch it again whenever you like."
}
}
},
@@ -206,12 +212,33 @@
"body": "The transcription of your meeting is in progress. You will receive the result by email once the meeting is finished.",
"button": "Stop transcription"
},
"stopping": {
"heading": "Saving your data…",
"body": "This process may take a few minutes. Thank you for your patience."
},
"beta": {
"heading": "Become a beta tester",
"body": "Record your meeting for later. You will receive a summary by email once the meeting is finished.",
"button": "Sign up"
}
},
"screenRecording": {
"start": {
"heading": "Record this call",
"body": "Record this call to watch it later and receive the video recording by email.",
"button": "Start recording",
"linkMore": "Learn more"
},
"stopping": {
"heading": "Saving your data…",
"body": "This process may take a few minutes. Thank you for your patience."
},
"stop": {
"heading": "Recording in progress…",
"body": "You will receive the result by email once the recording is complete.",
"button": "Stop recording"
}
},
"admin": {
"description": "These organizer settings allow you to maintain control of your meeting. Only organizers can access these controls.",
"access": {
@@ -291,11 +318,19 @@
}
}
},
"recording": {
"recordingBadge": {
"transcript": {
"started": "Transcribing",
"starting": "Transcription starting",
"stopping": "Transcription stopping"
},
"screenRecording": {
"started": "Recording in progress",
"starting": "Starting recording",
"stopping": "Stopping recording"
},
"any": {
"started": "Recording in progress"
}
},
"participantTileFocus": {
@@ -22,6 +22,12 @@
"open": "Afficher",
"accept": "Accepter"
},
"recordingStarted": "{{name}} a démarré la transcription de la réunion.",
"recordingStopped": "{{name}} a arrêté la transcription de la réunion."
"transcript": {
"started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion."
},
"screenRecording": {
"started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion."
}
}
+37 -2
View File
@@ -168,6 +168,7 @@
"effects": "Effets",
"chat": "Messages dans l'appel",
"transcript": "Transcription",
"screenRecording": "Enregistrement",
"admin": "Commandes de l'organisateur",
"tools": "Plus d'outils"
},
@@ -176,6 +177,7 @@
"effects": "les effets",
"chat": "les messages",
"transcript": "transcription",
"screenRecording": "enregistrement",
"admin": "commandes de l'organisateur",
"tools": "plus d'outils"
},
@@ -190,7 +192,11 @@
"tools": {
"transcript": {
"title": "Transcription",
"body": "Transcrivez votre réunion pour plus tard"
"body": "Conservez une trace écrite de votre réunion."
},
"screenRecording": {
"title": "Enregistrement",
"body": "Enregistrez votre réunion pour la revoir quand vous le souhaitez."
}
}
},
@@ -206,12 +212,33 @@
"body": "La transcription de votre réunion est en cours. Vous recevrez le resultat par email une fois la réunion terminée.",
"button": "Arrêter la transcription"
},
"stopping": {
"heading": "Sauvegarde de vos données…",
"body": "Cette opération peut durer quelques minutes. Merci de votre patience."
},
"beta": {
"heading": "Devenez beta testeur",
"body": "Enregistrer votre réunion pour plus tard. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
"button": "Inscrivez-vous"
}
},
"screenRecording": {
"start": {
"heading": "Enregistrer cet appel",
"body": "Enregistrez cet appel pour plus tard et recevez l'enregistrement vidéo par mail.",
"button": "Démarrer l'enregistrement",
"linkMore": "En savoir plus"
},
"stopping": {
"heading": "Sauvegarde de vos données…",
"body": "Cette opération peut durer quelques minutes. Merci de votre patience."
},
"stop": {
"heading": "Enregistrement en cours …",
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
"button": "Arrêter l'enregistrement"
}
},
"admin": {
"description": "Ces paramètres organisateur vous permettent de garder le contrôle de votre réunion. Seuls les organisateurs peuvent accéder à ces commandes.",
"access": {
@@ -291,11 +318,19 @@
}
}
},
"recording": {
"recordingBadge": {
"transcript": {
"started": "Transcription en cours",
"starting": "Démarrage de la transcription",
"stopping": "Arrêt de la transcription"
},
"screenRecording": {
"started": "Enregistrement en cours",
"starting": "Démarrage de l'enregistrement",
"stopping": "Arrêt de l'enregistrement"
},
"any": {
"started": "Enregistrement en cours"
}
},
"participantTileFocus": {
@@ -22,6 +22,12 @@
"open": "Openen",
"accept": "Accepteren"
},
"recordingStarted": "{{name}} is de transcriptie van de vergadering gestart.",
"recordingStopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
"transcript": {
"started": "{{name}} is de transcriptie van de vergadering gestart.",
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt."
},
"screenRecording": {
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
"stopped": "{{name}} is gestopt met het opnemen van de vergadering."
}
}
+38 -3
View File
@@ -168,6 +168,7 @@
"effects": "Effecten",
"chat": "Berichten in de chat",
"transcript": "Transcriptie",
"screenRecording": "Schermopname",
"admin": "Beheerdersbediening",
"tools": "Meer tools"
},
@@ -175,7 +176,8 @@
"participants": "deelnemers",
"effects": "effecten",
"chat": "berichten",
"transcript": "transcriptie",
"screenRecording": "transcriptie",
"transcript": "schermopname",
"admin": "beheerdersbediening",
"tools": "meer tools"
},
@@ -190,7 +192,11 @@
"tools": {
"transcript": {
"title": "Transcriptie",
"body": "Transcribeer je vergadering voor later"
"body": "Bewaar een schriftelijk verslag van je vergadering."
},
"screenRecording": {
"title": "Opname",
"body": "Neem je vergadering op om die later opnieuw te bekijken."
}
}
},
@@ -206,12 +212,33 @@
"body": "De transcriptie van uw vergadering is bezig. U ontvangt het resultaat per e-mail zodra de vergadering is afgelopen.",
"button": "Transcriptie stoppen"
},
"stopping": {
"heading": "Uw gegevens worden opgeslagen…",
"body": "Dit proces kan enkele minuten duren. Bedankt voor uw geduld."
},
"beta": {
"heading": "Word betatester",
"body": "Neem uw vergadering op voor later. U ontvangt een samenvatting per e-mail zodra de vergadering is afgelopen.",
"button": "Aanmelden"
}
},
"screenRecording": {
"start": {
"heading": "Dit gesprek opnemen",
"body": "Neem dit gesprek op om het later terug te kijken. Je ontvangt de video-opname per e-mail.",
"button": "Opname starten",
"linkMore": "Meer informatie"
},
"stopping": {
"heading": "Uw gegevens worden opgeslagen…",
"body": "Dit proces kan enkele minuten duren. Bedankt voor uw geduld."
},
"stop": {
"heading": "Opname bezig …",
"body": "Je ontvangt het resultaat per e-mail zodra de opname is voltooid.",
"button": "Opname stoppen"
}
},
"admin": {
"description": "Deze organisatorinstellingen geven u controle over uw vergadering. Alleen organisatoren hebben toegang tot deze bedieningselementen.",
"access": {
@@ -291,11 +318,19 @@
}
}
},
"recording": {
"recordingBadge": {
"transcript": {
"started": "Transcriptie bezig",
"starting": "Transcriptie begint",
"stopping": "Transcriptie stopt"
},
"screenRecording": {
"started": "Opname bezig",
"starting": "Opname starten",
"stopping": "Opname stoppen"
},
"any": {
"started": "Opname bezig"
}
},
"participantTileFocus": {
+20
View File
@@ -0,0 +1,20 @@
import { proxy } from 'valtio'
export enum RecordingStatus {
TRANSCRIPT_STARTING,
TRANSCRIPT_STARTED,
TRANSCRIPT_STOPPING,
STOPPED,
SCREEN_RECORDING_STARTING,
SCREEN_RECORDING_STARTED,
SCREEN_RECORDING_STOPPING,
ANY_STARTED,
}
type State = {
status: RecordingStatus
}
export const recordingStore = proxy<State>({
status: RecordingStatus.STOPPED,
})
-16
View File
@@ -1,16 +0,0 @@
import { proxy } from 'valtio'
export enum TranscriptionStatus {
STARTING,
STARTED,
STOPPING,
STOPPED,
}
type State = {
status: TranscriptionStatus
}
export const transcriptionStore = proxy<State>({
status: TranscriptionStatus.STOPPED,
})
+3
View File
@@ -9,3 +9,6 @@ export const CRISP_HELP_ARTICLE_MORE_TOOLS =
export const CRISP_HELP_ARTICLE_TRANSCRIPT =
'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x' as const
export const CRISP_HELP_ARTICLE_RECORDING =
'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0' as const
@@ -16,6 +16,10 @@ backend:
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_SUPPORT_EMAIL: "test@yopmail.com"
DJANGO_EMAIL_LOGO_IMG: https://meet.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_DOMAIN: https://meet.127.0.0.1.nip.io
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
@@ -61,6 +65,7 @@ backend:
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN: password
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
SSL_CERT_FILE: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
@@ -195,3 +200,17 @@ celery:
- "worker"
- "--pool=solo"
- "--loglevel=info"
ingressMedia:
enabled: true
host: meet.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
serviceMedia:
host: minio.meet.svc.cluster.local
port: 9000
@@ -32,6 +32,10 @@ backend:
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_SUPPORT_EMAIL: "test@yopmail.com"
DJANGO_EMAIL_LOGO_IMG: https://meet.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_DOMAIN: https://meet.127.0.0.1.nip.io
OIDC_OP_JWKS_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/jwks
OIDC_OP_AUTHORIZATION_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/authorize
OIDC_OP_TOKEN_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/token
@@ -83,6 +87,7 @@ backend:
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN: password
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL: True
BREVO_API_KEY:
secretKeyRef:
@@ -223,3 +228,17 @@ celery:
- "worker"
- "--pool=solo"
- "--loglevel=info"
ingressMedia:
enabled: true
host: meet.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
serviceMedia:
host: minio.meet.svc.cluster.local
port: 9000
+44 -28
View File
@@ -4,34 +4,50 @@
### General configuration
| Name | Description | Value |
| ------------------------------------------ | ---------------------------------------------------- | ---------------------- |
| `image.repository` | Repository to use to pull meet's container image | `lasuite/meet-backend` |
| `image.tag` | meet's container tag | `latest` |
| `image.pullPolicy` | Container image pull policy | `IfNotPresent` |
| `image.credentials.username` | Username for container registry authentication | |
| `image.credentials.password` | Password for container registry authentication | |
| `image.credentials.registry` | Registry url for which the credentials are specified | |
| `image.credentials.name` | Name of the generated secret for imagePullSecrets | |
| `nameOverride` | Override the chart name | `""` |
| `fullnameOverride` | Override the full application name | `""` |
| `ingress.enabled` | whether to enable the Ingress or not | `false` |
| `ingress.className` | IngressClass to use for the Ingress | `nil` |
| `ingress.host` | Host for the Ingress | `meet.example.com` |
| `ingress.path` | Path to use for the Ingress | `/` |
| `ingress.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingress.customBackends` | Add custom backends to ingress | `[]` |
| `ingressAdmin.enabled` | whether to enable the Ingress or not | `false` |
| `ingressAdmin.className` | IngressClass to use for the Ingress | `nil` |
| `ingressAdmin.host` | Host for the Ingress | `meet.example.com` |
| `ingressAdmin.path` | Path to use for the Ingress | `/admin` |
| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| Name | Description | Value |
| ---------------------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------- |
| `image.repository` | Repository to use to pull meet's container image | `lasuite/meet-backend` |
| `image.tag` | meet's container tag | `latest` |
| `image.pullPolicy` | Container image pull policy | `IfNotPresent` |
| `image.credentials.username` | Username for container registry authentication | |
| `image.credentials.password` | Password for container registry authentication | |
| `image.credentials.registry` | Registry url for which the credentials are specified | |
| `image.credentials.name` | Name of the generated secret for imagePullSecrets | |
| `nameOverride` | Override the chart name | `""` |
| `fullnameOverride` | Override the full application name | `""` |
| `ingress.enabled` | whether to enable the Ingress or not | `false` |
| `ingress.className` | IngressClass to use for the Ingress | `nil` |
| `ingress.host` | Host for the Ingress | `meet.example.com` |
| `ingress.path` | Path to use for the Ingress | `/` |
| `ingress.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingress.customBackends` | Add custom backends to ingress | `[]` |
| `ingressAdmin.enabled` | whether to enable the Ingress or not | `false` |
| `ingressAdmin.className` | IngressClass to use for the Ingress | `nil` |
| `ingressAdmin.host` | Host for the Ingress | `meet.example.com` |
| `ingressAdmin.path` | Path to use for the Ingress | `/admin` |
| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMedia.enabled` | whether to enable the Ingress or not | `false` |
| `ingressMedia.className` | IngressClass to use for the Ingress | `nil` |
| `ingressMedia.host` | Host for the Ingress | `meet.example.com` |
| `ingressMedia.path` | Path to use for the Ingress | `/media/(.*)` |
| `ingressMedia.hosts` | Additional host to configure for the Ingress | `[]` |
| `ingressMedia.tls.enabled` | Weather to enable TLS for the Ingress | `true` |
| `ingressMedia.tls.secretName` | Secret name for TLS config | `nil` |
| `ingressMedia.tls.additional[].secretName` | Secret name for additional TLS config | |
| `ingressMedia.tls.additional[].hosts[]` | Hosts for additional TLS config | |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url` | | `https://meet.example.com/api/v1.0/recordings/media-auth/` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers` | | `Authorization, X-Amz-Date, X-Amz-Content-SHA256` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost` | | `minio.meet.svc.cluster.local:9000` |
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet` | | `add_header Content-Security-Policy "default-src 'none'" always;` |
| `serviceMedia.host` | | `minio.meet.svc.cluster.local` |
| `serviceMedia.port` | | `9000` |
| `serviceMedia.annotations` | | `{}` |
### backend
@@ -0,0 +1,83 @@
{{- if .Values.ingressMedia.enabled -}}
{{- $fullName := include "meet.fullname" . -}}
{{- if and .Values.ingressMedia.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingressMedia.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingressMedia.annotations "kubernetes.io/ingress.class" .Values.ingressMedia.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.labels" . | nindent 4 }}
{{- with .Values.ingressMedia.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingressMedia.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingressMedia.className }}
{{- end }}
{{- if .Values.ingressMedia.tls.enabled }}
tls:
{{- if .Values.ingressMedia.host }}
- secretName: {{ .Values.ingressMedia.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
hosts:
- {{ .Values.ingressMedia.host | quote }}
{{- end }}
{{- range .Values.ingressMedia.tls.additional }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- if .Values.ingressMedia.host }}
- host: {{ .Values.ingressMedia.host | quote }}
http:
paths:
- path: {{ .Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: ImplementationSpecific
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- range .Values.ingressMedia.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ $.Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: ImplementationSpecific
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- end }}
+14
View File
@@ -0,0 +1,14 @@
{{- $fullName := include "meet.fullname" . -}}
{{- $component := "media" -}}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
annotations:
{{- toYaml $.Values.serviceMedia.annotations | nindent 4 }}
spec:
type: ExternalName
externalName: {{ $.Values.serviceMedia.host }}
+42
View File
@@ -69,6 +69,48 @@ ingressAdmin:
enabled: true
additional: []
## @param ingressMedia.enabled whether to enable the Ingress or not
## @param ingressMedia.className IngressClass to use for the Ingress
## @param ingressMedia.host Host for the Ingress
## @param ingressMedia.path Path to use for the Ingress
ingressMedia:
enabled: false
className: null
host: meet.example.com
path: /media/(.*)
## @param ingressMedia.hosts Additional host to configure for the Ingress
hosts: [ ]
# - chart-example.local
## @param ingressMedia.tls.enabled Weather to enable TLS for the Ingress
## @param ingressMedia.tls.secretName Secret name for TLS config
## @skip ingressMedia.tls.additional
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
secretName: null
additional: []
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost
## @param ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet
annotations:
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/recordings/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header Content-Security-Policy "default-src 'none'" always;
## @param serviceMedia.host
## @param serviceMedia.port
## @param serviceMedia.annotations
serviceMedia:
host: minio.meet.svc.cluster.local
port: 9000
annotations: {}
## @section backend
+67
View File
@@ -0,0 +1,67 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
<mj-section css-class="wrapper-logo">
<mj-column>
<mj-image
align="center"
src="{{logo_img}}"
width="320px"
alt="{%trans 'Logo email' %}"
/>
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="0px 20px 60px 20px">
<mj-column>
<mj-text align="center">
<h1>{% trans "Your recording is ready!"%}</h1>
</mj-text>
<!-- Main Message -->
<mj-text>
{% blocktrans %}
Your recording of "{{room_name}}" on {{recording_date}} at {{recording_time}} is now ready to download.
{% endblocktrans %}
</mj-text>
<mj-text>
<p>{% trans "To keep this recording permanently:" %}</p>
<ol>
<li>{% trans "Click the \"Open\" button below" %}</li>
<li>{% trans "Use the \"Download\" button in the interface" %}</li>
<li>{% trans "Save the file to your preferred location" %}</li>
</ol>
</mj-text>
<mj-button
href="{{link}}"
background-color="#000091"
color="white"
padding-bottom="30px"
>
{% trans "Open"%}
</mj-button>
<mj-text>
{% blocktrans %}
If you have any questions or need assistance, please contact our support team at {{support_email}}.
{% endblocktrans %}
</mj-text>
<mj-divider
border-width="1px"
border-style="solid"
border-color="#DDDDDD"
width="30%"
align="center"
/>
<!-- Signature -->
<mj-text>
<p>
{% blocktrans %}
Thank you for using {{brandname}}.
{% endblocktrans %}
</p>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.17",
"version": "0.1.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.17",
"version": "0.1.18",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.17",
"version": "0.1.18",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "0.1.17",
"version": "0.1.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "0.1.17",
"version": "0.1.18",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "0.1.17",
"version": "0.1.18",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",