Compare commits

...

66 Commits

Author SHA1 Message Date
lebaudantoine 93f8ce687c ♻️(backend) remove internationalization from non-user-facing strings
Remove translation markers from backend strings that are never displayed to
users. Streamlines localization process by focusing only on user-visible
content that requires actual translation.
2025-05-15 12:05:59 +02:00
lebaudantoine 952104fd82 🌐(i18n) add German language support
Implement German translations throughout the application to better serve
German-speaking users. Expands language options beyond existing French,
English, and Dutch to improve accessibility for German counterparts.
2025-05-15 12:04:17 +02:00
lebaudantoine 60dc8bf174 🌐(backend) update translation files after authentication refactoring
Refresh Django translation files to remove strings from authentication
system refactoring and recent minor backend changes.
2025-05-15 12:04:17 +02:00
renovate[bot] d54a61cbcc ⬆️(dependencies) update vite [SECURITY] 2025-05-12 16:33:05 +02:00
lebaudantoine f90a1e3549 🚨(frontend) resolve TypeScript build errors after dependencies upgrade
Fix type checking failures caused by recent dependency updates.
2025-05-12 16:00:23 +02:00
renovate[bot] 572b80d3fe ⬆️(dependencies) update js dependencies 2025-05-12 16:00:23 +02:00
Ghislain LE MEUR 82d840a15f 🔧(helm) remove affinity for jobs
Affinity isn't necessary for jobs.
Please have a look to PR #509
2025-05-12 14:34:40 +02:00
renovate[bot] 500c690fa0 ⬆️(dependencies) update django to v5.1.9 [SECURITY] 2025-05-12 14:18:52 +02:00
lebaudantoine 577111d864 🔒️(frontend) prevent disconnected users from accessing recording tools
Block recording and transcript features when user isn't connected to prevent
database state corruption. Users were previously able to trigger these
actions despite being disconnected.
2025-05-12 11:46:32 +02:00
lebaudantoine 563f1e4c0f (frontend) improve meeting code input accessibility for touch devices
Enhance meeting code input to accept codes without hyphens and make input
case-insensitive. Addresses usability issue observed with touch screen and
virtual keyboard users who struggled with precise formatting. Improves
accessibility for users with pointing pens and limited input precision.

requested by @spaccoud
2025-05-12 10:34:49 +02:00
lebaudantoine 2e8407ac7c 🚸(frontend) visually differentiate user's reactions from others
Implement light color variant for reactions triggered by current user versus
standard color for other participants' reactions. Provides visual cue to help
users easily identify their own emoji reactions in the conversation flow.
2025-05-05 23:27:22 +02:00
lebaudantoine 2af9ec0d85 ♻️(frontend) replace UTF emoji characters with designer-created images
Replace UTF character-based emoji with custom image assets designed by our
UX/UI team. Enhances cross-platform compatibility of reactions that were
previously inconsistent between operating systems. Specifically addresses
issue where emoji sent from Mac weren't properly displayed on all client
systems.
2025-05-05 23:27:22 +02:00
lebaudantoine b70799c2db 🔖(minor) bump release to 0.1.22
Upgrade track processor to benefits from webgl.
2025-05-05 23:26:49 +02:00
lebaudantoine 0facfc11be 🐛(frontend) fix video distortion when stopping processors
Prevent layout shift in vertical menu by adapting video element height based on
orientation. Eliminates glitchy effect where stopping a processor doubled
video height and pushed menu options downward for a few ms.
2025-05-03 19:13:29 +02:00
lebaudantoine 8023e44f71 🥚(frontend) add Konami code detector to unlock April Fool's effects
Implement easter egg that reveals fun features originally created for
April Fool's Day when users enter the Konami sequence.
2025-05-03 19:13:29 +02:00
lebaudantoine b27c5e9b92 🏗️(frontend) decouple landmark processor from background processors
Separate landmark processor logic to avoid entanglement with background
processing. Ensures future refactoring can replace custom background
implementation without affecting landmark functionality.
2025-05-03 19:13:29 +02:00
lebaudantoine 3e5c4c32e9 🔥(frontend) remove version upgrade warning for LiveKit WebGL update
Delete obsolete user notification now that the stable WebGL-powered version
with improved performance has been officially released.
2025-05-03 19:13:29 +02:00
lebaudantoine 551207ab86 🩹(frontend) add conditional stopProcessor call for cross-browser compat
Implement browser detection to explicitly stop processors in Firefox and other
browsers that lack full support for modern web APIs, before switching from
one processor to another.

This issue was introduced by recent upgrade of track processor.
An issue has been opened.
2025-05-03 19:13:29 +02:00
lebaudantoine 0aa47fcd1e ⬆️ (frontend) upgrade track processor to enable WebGL support
Update dependency to gain improved browser compatibility and WebGL
acceleration for better video processing performance.
2025-05-03 19:13:29 +02:00
virgile-deville c289f79d8e 📝(readme) add matrix room to readme
So that community members can gather and chat about meet

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-05-03 19:05:05 +02:00
lebaudantoine b79fa14919 🔖(minor) bump release to 0.1.21
Refactor authentication.
Address few bug bounties.
2025-05-01 16:49:30 +02:00
lebaudantoine c7c0df5b6d 🚸(frontend) add alert dialog for recording start failures
Implement modal alert dialog when recording initialization fails. Provides
clear error feedback to users when API cannot start recording process,
improving error state communication.
2025-04-30 14:54:41 +02:00
lebaudantoine cb00347be6 🚸(frontend) show spinner immediately on recording request initiation
Display loading spinner when recording request is sent instead of waiting
for API confirmation. Provides immediate feedback during slow server
responses to improve perceived responsiveness.
2025-04-30 14:54:41 +02:00
lebaudantoine bcb004ab4b 🥅(backend) add broad exception handling for non-twirp error in recording
Implement broad exception handling to catch any non-twirp errors
during recording operations. Ensures recording status is properly reset to
"failed to start" when errors occur, allowing users to retry the recording
while still logging errors to Sentry for investigation.

It's generally a bad practice, however in this case it's fine, I am
catching exception beforehand and it only acts as a fallback.
2025-04-30 14:54:41 +02:00
lebaudantoine 422f838899 🔒️(backend) remove accesses list from room serializer for non-admins
Restrict access to room user permissions data by excluding this information
from room serializer response for non-admin/owner users. Previously all
members could see complete access lists. Change enforces stricter information
access control based on user role.

Spotted in #YWH-PGM14336-5.
2025-04-30 14:13:30 +02:00
lebaudantoine 462c6c50e5 🔒️(backend) disable BrowsableAPIRenderer to prevent information leakage
Remove BrowsableAPIRenderer from API options, restricting output to JSON
format only. Prevents leakage of sensitive information like resource IDs and
user identifiers that were previously exposed in renderer dropdown options.

Issue identified in #YWH-PGM14336-4 report.
These information was considered as a critical disclosure by hackers.
2025-04-30 14:13:30 +02:00
lebaudantoine 63565b38c3 ♻️(backend) simplify ResourceAccess viewset implementation
Restructure ResourceAccess viewset to align with Room and Recording viewset
patterns. Clean up implementation while preserving identical behavior and
API contract. Improves code consistency and maintainability across related
viewsets.

ResourceAccessPermission inherits from IsAuthenticated.
2025-04-30 14:13:30 +02:00
Quentin BEY 10d759bdbb (backend) add django-lasuite dependency
Use the OIDC backend from the `django-lasuite` library
2025-04-28 23:38:45 +02:00
lebaudantoine 51f1f0ebbf 🔖(minor) bump release to 0.1.20
Misc fixes.
2025-04-28 23:13:31 +02:00
lebaudantoine 9e27d0f345 🚑️(frontend) throttle emoji reaction sending to prevent DoS attacks
Apply rate limiting to emoji reactions after discovering malicious users using
auto-clickers to flood the system and crash other participants' apps.
2025-04-28 20:08:47 +02:00
lebaudantoine 978d931bd7 (frontend) create hook for rate-limiting functions that could spam UI
Implement custom hook to throttle JavaScript function calls and prevent
interface performance degradation.
2025-04-28 20:08:47 +02:00
lebaudantoine 0c811222d4 ♻️(frontend) change Panel keepAlive default to false for all side panels
Reverse default behavior for Panel component to unmount content from DOM when
closed instead of keeping it alive. Makes DOM updates more lightweight by
removing unused panel content. Improves performance particularly in complex
room with hundred of participants.

Exception made for chat panel which retains keepAlive=true to preserve
unsent messages that users may want to submit later.
2025-04-28 18:04:26 +02:00
lebaudantoine 94171dcb82 (frontend) add keepAlive option to Panel component
Implement new keepAlive property for Panel component to control DOM retention
when panel is closed. When false, panel content is unmounted from DOM on
close, resetting scroll position and input states. Provides finer control
over panel behavior and memory management.
2025-04-28 18:04:26 +02:00
lebaudantoine 56c1cd98fa 🔧(frontend) make feedback form configurable via backend settings
Implement conditional rendering that hides all feedback-related UI components
when feedback is disabled in backend configuration.

Also, feedback URL is now customizable.
2025-04-28 17:37:31 +02:00
lebaudantoine f2e6edb90d 🚩(frontend) disable meeting rating when analytics is not configured
Hide the rating module in the feedback route when analytics service is
unavailable to self host La Suite Meet without analytics.
2025-04-28 17:37:31 +02:00
lebaudantoine bc76c44fe9 ♻️(frontend) refactor ParticipantName component for internationalization
Component now supports i18n. The participant tile needs further refactoring as
it still mixes LiveKit CSS with custom styling.
2025-04-28 16:49:06 +02:00
lebaudantoine e519f00342 🔥(frontend) remove duplicated aria-label from join screen username input
Eliminate redundant accessibility attribute that wasn't providing relevant
information to screen readers.
2025-04-28 16:48:52 +02:00
lebaudantoine 2246bb7782 ️(frontend) lower silent login retry interval from 5 min to 30 sec
This change enhances user experience by making automatic login more responsive.
Only occurs on app navigation/refresh, not internal navigation.

Testing in production, can revert if needed. Will later refactor to use
backend configuration.
2025-04-28 13:21:17 +02:00
lebaudantoine e210f26f9c 🐛(frontend) prevent silent login in webmail iframe integration
Fix calendar integration by preventing silent login triggers within webmail
iframes. Refactor code to only initialize app components when not in SDK
context. Resolves unexpected behavior in Firefox where iframes were
incorrectly rendering the homepage instead of intended calendar content.
2025-04-28 13:21:17 +02:00
lebaudantoine 888dfe76c7 🐛(backend) resolve backchannel calls to LiveKit in docker-compose
Fix container networking issue where app-dev container couldn't resolve
localhost address when calling LiveKit API. Update configuration to use
proper container network addressing for backchannel communication between
services.
2025-04-25 12:52:14 +02:00
lebaudantoine ae17fbdaa8 ♻️(backend) extract livekit API client creation to reusable utility
Create dedicated utility function for livekit API client initialization.
Centralizes configuration logic including custom session handling for SSL
verification. Improves code reuse across backend components that interact
with LiveKit.
2025-04-24 18:05:52 +02:00
lebaudantoine 2ef95aa835 ♻️(backend) update BaseEgress to use custom session from livekit-api
Refactor BaseEgress class to leverage latest livekit-api client's custom
session support. Simplifies code by using built-in capability to disable SSL
verification in development environments instead of previous workaround.
2025-04-24 18:05:52 +02:00
lebaudantoine a83e5c4b1c 🔥(backend) delete overly complex BaseEgress tests
Remove BaseEgress tests that were overly complicated and had excessive
mocking, making them unrealistic and difficult to maintain. Will replace with
more straightforward tests in future commits that better reflect actual code
behavior.
2025-04-24 18:05:52 +02:00
lebaudantoine 9cc79ba159 🩹(backend) correct typo in WorkerConfig parameter name
Fix minor spelling error in WorkerConfig parameter that had no functional
impact but improves code clarity and consistency.
2025-04-24 18:05:52 +02:00
lebaudantoine c63adf9c8c ⬆️(backend) upgrade livekit-api to latest version
Update livekit-api dependency to most recent release, enabling custom session
configuration. New version allows disabling SSL verification in local
development environment through session parameter support.
2025-04-24 18:05:52 +02:00
lebaudantoine 9366c8c4dd 🔖(minor) bump release to 0.1.19
Introduce screen recording.
2025-04-23 23:45:20 +02:00
lebaudantoine 3636bfa703 (frontend) display recording expiration status in frontend
Add recording expiration information to frontend interface, showing number
of days until expiration or indicating permanent status. For expired
recordings, display the date since which content has been unavailable.
Improves transparency about content lifecycle.
2025-04-23 19:52:29 +02:00
lebaudantoine 34c14cc516 (mail) include expiration information in recording notification emails
Add validity duration (number of days valid) to email
notifications for recordings. Informs users about their recording's lifespan,
providing important context about content availability.
2025-04-23 19:52:29 +02:00
lebaudantoine 1a0051a90b (backend) implement recording expiration mechanism
Add expiration system for recordings.

Include option for users to set recordings as permanent (no expiration)
which is the default behavior.

System only calculates expiration dates and tracks status - actual deletion
is handled by Minio bucket lifecycle policies, not by application code.
2025-04-23 18:53:42 +02:00
renovate[bot] af21478143 ⬆️(dependencies) update vite [SECURITY] 2025-04-23 15:36:44 +02:00
lebaudantoine 986b75ba39 (backend) personalize recording notification emails by user preferences
Customize email notifications for recording availability based on each user's
language and timezone settings. Improves user experience through localized
communications.

Prioritize simple, maintainable implementation over complex code that would
form subgroups based on user preferences. Note: Changes individual email
sending instead of batch processing, which may impact performance for large
groups but is acceptable for typical recording access patterns.
2025-04-23 15:36:44 +02:00
lebaudantoine df55fb2424 🐛(backend) rename copy-pasted unit tests
Fix inconsistent test naming resulting from copy-pasted examples. Rename
tests to properly reflect their actual testing purpose and improve code
maintainability.
2025-04-23 14:17:09 +02:00
lebaudantoine ec114808b2 (frontend) add hook to sync user preferences
Implement new hook that synchronizes user language and timezone preferences
from frontend to backend. Ensures consistent localization across and
notifications. Note: Current implementation works but auth code requires
future refactoring.
2025-04-23 14:17:09 +02:00
lebaudantoine 7f0e866eac ♻️(frontend) rename isLoading to isConfigLoading for clarity
Rename generic "isLoading" variable to more specific "isConfigLoading" to
accurately reflect its purpose. Improves code readability by making state
variable names more explicit.
2025-04-23 14:17:09 +02:00
lebaudantoine 8e0e286bc4 (backend) serialize user language and timezone for frontend use
Add user language and timezone to serialized user data to enable frontend
customization. Allows backend email notifications to respect user's
localization preferences for improved communication relevance.
2025-04-23 14:17:09 +02:00
lebaudantoine ee9148fe9f 🌐(backend) add and compile Dutch translations for backend
Add Dutch language translations for backend text strings and compile
translation files for production use. Improves localization support for
Dutch-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine c863bc7456 🌐(backend) add and compile English translations for backend
Add English language translations for backend text strings and compile
translation files for production use. Improves localization support for
English-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine eab361326f 🌐(backend) add and compile French translations for backend
Add French language translations for backend text strings and compile
translation files for production use. Improves localization support for
French-speaking users.
2025-04-23 14:17:09 +02:00
lebaudantoine 86c82f584c 🌐(backend) convert translation tags to blocktrans for quoted strings
Replace simple trans tags with blocktrans tags in download instructions
to properly handle quoted text in translations. Ensures quotes within
translated strings are correctly preserved during localization.
2025-04-23 14:17:09 +02:00
lebaudantoine af6ac954e9 🌐(backend) add Dutch language support to backend
Add Dutch (nl) language configuration to backend to match available frontend
languages. Ensures consistent language options across the entire application.
2025-04-23 14:17:09 +02:00
lebaudantoine 35cc6cd902 🌐(backend) regenerate translation files including email templates
Update translation files to include previously missed strings from email
templates. Ensures complete localization coverage across all backend
components including notification emails.
2025-04-23 14:17:09 +02:00
lebaudantoine 9cb7b0f154 ️(frontend) improve mic button accessibility in participant list
Add aria-label to microphone buttons in participant list to clearly indicate
mute functionality for current user and other participants. Set
aria-hidden on SVG icons to prevent redundant screen reader announcements.
2025-04-23 10:02:41 +02:00
lebaudantoine d105603a9b 🔖(helm) bump Helm chart version after job name modifications
Increment Helm chart version to reflect changes to backend job component
naming. Ensures proper versioning of configuration changes in deployment
pipeline.
2025-04-22 18:26:40 +02:00
lebaudantoine d2da1e37b9 🚚(helm) specify unique component names for all backend jobs
Update backend job configurations to use distinct component names instead of
sharing names with deployments. Prevents conflicts during cluster updates
and migrations that were causing unexpected behavior. Improves deployment
reliability and resource identification.
2025-04-22 18:26:40 +02:00
lebaudantoine 0206762e6d 🚚(helm) rename migration job to more explicit 'backend_job_migrate'
Rename backend migration job to a more descriptive name that clearly
indicates its purpose. Improves code clarity and makes deployment
configuration more self-documenting.
2025-04-22 18:26:40 +02:00
lebaudantoine c4fe341a74 🔧(backend) make data directory location configurable via env var
Add environment variable to control data directory location when building
outside of Docker. Improves flexibility for non-containerized deployments
where storing data at filesystem root is inappropriate or undesirable.
2025-04-22 16:25:59 +02:00
129 changed files with 5389 additions and 4810 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
</p>
<p align="center">
<a href="https://livekit.io/">LiveKit</a> - <a href="https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
</p>
<p align="center">
+2
View File
@@ -72,6 +72,8 @@ services:
- nginx
- livekit
- createbuckets
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
celery-dev:
user: ${DOCKER_USER:-1000}
+2 -1
View File
@@ -287,6 +287,7 @@ These are the environmental options available on meet backend.
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | oidc endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
| OIDC_RP_SCOPES | oidc scopes | openid email |
@@ -301,6 +302,7 @@ These are the environmental options available on meet backend.
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | required claims from OIDC token | [] |
| LIVEKIT_API_KEY | livekit api key | |
| LIVEKIT_API_SECRET | livekit api secret | |
| LIVEKIT_API_URL | livekit api url | |
@@ -308,7 +310,6 @@ These are the environmental options available on meet backend.
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | record meeting option | false |
| RECORDING_OUTPUT_FOLDER | folder to store meetings | recordings |
| RECORDING_VERIFY_SSL | verify ssl for recording storage | true |
| RECORDING_WORKER_CLASSES | worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | enable storage event authorization | true |
+2 -1
View File
@@ -48,7 +48,8 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Livekit Token settings
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://localhost:7880
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
LIVEKIT_VERIFY_SSL=False
ALLOW_UNREGISTERED_ROOMS=False
# Recording
+1
View File
@@ -40,6 +40,7 @@ def get_frontend_configuration(request):
"recording": {
"is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
+16 -3
View File
@@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
@@ -11,9 +12,11 @@ from core import models, utils
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
timezone = TimeZoneSerializerField()
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
@@ -117,7 +120,7 @@ class RoomSerializer(serializers.ModelSerializer):
role = instance.get_role(request.user)
is_admin = models.RoleChoices.check_administrator_role(role)
if role is not None:
if is_admin:
access_serializer = NestedResourceAccessSerializer(
instance.accesses.select_related("resource", "user").all(),
context=self.context,
@@ -156,7 +159,17 @@ class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Recording
fields = ["id", "room", "created_at", "updated_at", "status", "mode", "key"]
fields = [
"id",
"room",
"created_at",
"updated_at",
"status",
"mode",
"key",
"is_expired",
"expired_at",
]
read_only_fields = fields
+20 -29
View File
@@ -531,40 +531,12 @@ class RoomViewSet(
)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
def get_permissions(self):
"""User only needs to be authenticated to list rooms access"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
if self.action == "list":
user = self.request.user
queryset = queryset.filter(
Q(resource__accesses__user=user),
resource__accesses__role__in=[
models.RoleChoices.ADMIN,
models.RoleChoices.OWNER,
],
).distinct()
return queryset
class ResourceAccessViewSet(
ResourceAccessListModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -575,6 +547,25 @@ class ResourceAccessViewSet(
queryset = models.ResourceAccess.objects.all()
serializer_class = serializers.ResourceAccessSerializer
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
# Restrict access to resources the user either has explicit
# permissions for or administrative privileges over.
if self.action == "list":
user = self.request.user
queryset = queryset.filter(
Q(resource__accesses__user=user),
resource__accesses__role__in=[
models.RoleChoices.ADMIN,
models.RoleChoices.OWNER,
],
).distinct()
return queryset
class RecordingViewSet(
mixins.DestroyModelMixin,
+31 -106
View File
@@ -1,12 +1,13 @@
"""Authentication Backends for the Meet core app."""
import contextlib
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.utils.translation import gettext_lazy as _
import requests
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from core.models import User
@@ -17,93 +18,46 @@ from core.services.marketing import (
)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
"""
def get_userinfo(self, access_token, id_token, payload):
"""Return user details dictionary.
def get_extra_claims(self, user_info):
"""
Return extra claims from user_info.
Parameters:
- access_token (str): The access token.
- id_token (str): The id token (unused).
- payload (dict): The token payload (unused).
Note: The id_token and payload parameters are unused in this implementation,
but were kept to preserve base method signature.
Note: It handles signed and/or encrypted UserInfo Response. It is required by
Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response.
Args:
user_info (dict): The user information dictionary.
Returns:
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
dict: A dictionary of extra claims.
"""
user_response = requests.get(
self.OIDC_OP_USER_ENDPOINT,
headers={"Authorization": f"Bearer {access_token}"},
verify=self.get_settings("OIDC_VERIFY_SSL", True),
timeout=self.get_settings("OIDC_TIMEOUT", None),
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
userinfo = self.verify_token(user_response.text)
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
Parameters:
- access_token (str): The access token.
- id_token (str): The ID token.
- payload (dict): The user payload.
Returns:
- User: An existing or newly created User instance.
Raises:
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
email = user_info.get("email")
user = self.get_existing_user(sub, email)
claims = {
"email": email,
return {
# Get user's full name from OIDC fields defined in settings
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
}
if not user and self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(
sub=sub,
password="!", # noqa: S106
**claims,
)
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
def post_get_or_create_user(self, user, claims, is_new_user):
"""
Post-processing after user creation or retrieval.
elif not user:
return None
Args:
user (User): The user instance.
claims (dict): The claims dictionary.
is_new_user (bool): Indicates if the user was newly created.
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
Returns:
- None
self.update_user_if_needed(user, claims)
return user
"""
email = claims["email"]
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
@staticmethod
def signup_to_marketing_email(email):
@@ -116,7 +70,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
"""
try:
with contextlib.suppress(
ContactCreationError, ImproperlyConfigured, ImportError
):
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
@@ -124,8 +80,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
except (ContactCreationError, ImproperlyConfigured, ImportError):
pass
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
@@ -139,35 +93,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
pass
except User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
_("Multiple user accounts share a common email.")
"Multiple user accounts share a common email."
) from e
return None
@staticmethod
def compute_full_name(user_info):
"""Compute user's full name based on OIDC fields in settings."""
full_name = " ".join(
filter(
None,
(
user_info.get(field)
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
),
)
)
return full_name or None
@staticmethod
def update_user_if_needed(user, claims):
"""Update user claims if they have changed."""
user_fields = vars(user.__class__) # Get available model fields
updated_claims = {
key: value
for key, value in claims.items()
if value and key in user_fields and value != getattr(user, key)
}
if not updated_claims:
return
User.objects.filter(sub=user.sub).update(**updated_claims)
-18
View File
@@ -1,18 +0,0 @@
"""Authentication URLs for the People core app."""
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
urlpatterns = [
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
path(
"logout-callback/",
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozzila_oidc_urls,
]
-181
View File
@@ -1,181 +0,0 @@
"""Authentication Views for the People core app."""
import copy
from urllib.parse import urlencode
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
Adds support for handling logout callbacks from the identity provider (OP)
by initiating the logout flow if the user has an active session.
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
This parameter is crucial for maintaining the integrity of the logout flow between this call
and the subsequent callback.
"""
@staticmethod
def persist_state(request, state):
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
This method is used to store the OIDC state parameter in the session, according to the
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
utility function.
"""
if "oidc_states" not in request.session or not isinstance(
request.session["oidc_states"], dict
):
request.session["oidc_states"] = {}
request.session["oidc_states"][state] = {}
request.session.save()
def construct_oidc_logout_url(self, request):
"""Create the redirect URL for interfacing with the OIDC provider.
Retrieves the necessary parameters from the session and constructs the URL
required to initiate logout with the OpenID Connect provider.
If no ID token is found in the session, the logout flow will not be initiated,
and the method will return the default redirect URL.
The 'state' parameter is generated randomly and persisted in the session to ensure
its integrity during the subsequent callback.
"""
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
if not oidc_logout_endpoint:
return self.redirect_url
reverse_url = reverse("oidc_logout_callback")
id_token = request.session.get("oidc_id_token", None)
if not id_token:
return self.redirect_url
query = {
"id_token_hint": id_token,
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
"post_logout_redirect_uri": absolutify(request, reverse_url),
}
self.persist_state(request, query["state"])
return f"{oidc_logout_endpoint}?{urlencode(query)}"
def post(self, request):
"""Handle user logout.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, constructs the OIDC logout URL and redirects the user to start
the logout process.
If the user is redirected to the default logout URL, ensure her Django session
is terminated.
"""
logout_url = self.redirect_url
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
# If the user is not redirected to the OIDC provider, ensure logout
if logout_url == self.redirect_url:
auth.logout(request)
return HttpResponseRedirect(logout_url)
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
Handles the callback after logout from the identity provider (OP).
Verifies the state parameter and performs necessary logout actions.
The Django session is maintained during the logout process to ensure the integrity
of the logout flow initiated in the previous step.
"""
http_method_names = ["get"]
def get(self, request):
"""Handle the logout callback.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, verifies the state parameter and performs necessary logout actions.
"""
if not request.user.is_authenticated:
return HttpResponseRedirect(self.redirect_url)
state = request.GET.get("state")
if state not in request.session.get("oidc_states", {}):
msg = "OIDC callback state not found in session `oidc_states`!"
raise SuspiciousOperation(msg)
del request.session["oidc_states"][state]
request.session.save()
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
"""Custom callback view for handling the silent login flow."""
@property
def failure_url(self):
"""Override the failure URL property to handle silent login flow
A silent login failure (e.g., no active user session) should not be
considered as an authentication failure.
"""
if self.request.session.get("silent", None):
del self.request.session["silent"]
self.request.session.save()
return self.success_url
return super().failure_url
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
"""Custom authentication view for handling the silent login flow."""
def get_extra_params(self, request):
"""Handle 'prompt' extra parameter for the silent login flow
This extra parameter is necessary to distinguish between a standard
authentication flow and the silent login flow.
"""
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
if extra_params is None:
extra_params = {}
if request.GET.get("silent") == "true":
extra_params = copy.deepcopy(extra_params)
extra_params.update({"prompt": "none"})
request.session["silent"] = True
request.session.save()
return extra_params
@@ -0,0 +1,18 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_alter_room_access_level'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
+31 -1
View File
@@ -3,8 +3,9 @@ Declare and configure the models for the Meet core application
"""
import uuid
from datetime import datetime, timedelta
from logging import getLogger
from typing import List
from typing import List, Optional
from django.conf import settings
from django.contrib.auth import models as auth_models
@@ -12,6 +13,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.utils import timezone
from django.utils.functional import lazy
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -601,6 +603,34 @@ class Recording(BaseModel):
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
@property
def expired_at(self) -> Optional[datetime]:
"""
Calculate the expiration date based on created_at and RECORDING_EXPIRATION_DAYS.
Returns None if no expiration is configured.
Note: This is a naive and imperfect implementation since recordings are actually
saved to the bucket after created_at timestamp is set. The actual expiration
will be determined by the bucket lifecycle policy which operates on the object's
timestamp in the storage system, not this value.
"""
if not settings.RECORDING_EXPIRATION_DAYS:
return None
return self.created_at + timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
@property
def is_expired(self) -> bool:
"""
Determine if the recording has expired by comparing expired_at with current UTC time.
Returns False if no expiration is configured or if expiration date is in the future.
"""
if not self.expired_at:
return False
return self.expired_at < timezone.now()
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
@@ -55,7 +55,7 @@ class StorageEventAuthentication(BaseAuthentication):
if not required_token:
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
raise AuthenticationFailed(
_("Authentication is enabled but token is not configured.")
"Authentication is enabled but token is not configured."
)
return MachineUser(), None
@@ -67,7 +67,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Missing Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Authorization header is required"))
raise AuthenticationFailed("Authorization header is required")
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
@@ -75,7 +75,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Invalid authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Invalid authorization header."))
raise AuthenticationFailed("Invalid authorization header.")
token = auth_parts[1]
@@ -85,7 +85,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Invalid token (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed(_("Invalid token"))
raise AuthenticationFailed("Invalid token")
return MachineUser(), token
@@ -44,49 +44,69 @@ class NotificationService:
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,
owner_accesses = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.order_by("created_at")
)
if not owner_accesses:
logger.error("No owner found for recording %s", recording.id)
return False
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"),
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
}
emails = [access.user.email for access in owner_accesses]
has_failures = False
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,
# We process emails individually rather than in batch because:
# 1. Each email requires personalization (timezone, language)
# 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses:
user = access.user
language = user.language or get_language()
with override(language):
personalized_context = {
"recording_date": recording.created_at.astimezone(
user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
user.timezone
).strftime("%H:%M"),
**context,
}
msg_html = render_to_string(
"mail/html/screen_recording.html", personalized_context
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
return False
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = str(_("Your recording is ready")) # Force translation
return True
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
@staticmethod
def _notify_summary_service(recording):
@@ -17,7 +17,6 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
verify_ssl: Optional[bool]
bucket_args: Optional[dict]
@classmethod
@@ -29,7 +28,6 @@ class WorkerServiceConfig:
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION,
verify_ssl=settings.RECORDING_VERIFY_SSL,
bucket_args={
"endpoint": settings.AWS_S3_ENDPOINT_URL,
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
+18 -14
View File
@@ -2,11 +2,10 @@
# pylint: disable=no-member
import aiohttp
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from livekit.api.egress_service import EgressService
from ... import utils
from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
@@ -29,21 +28,26 @@ class BaseEgressService:
async def _handle_request(self, request, method_name: str):
"""Handle making a request to the LiveKit API and returns the response."""
# Use HTTP connector for local development with Tilt,
# where cluster communications are unsecure
connector = aiohttp.TCPConnector(ssl=self._config.verify_ssl)
lkapi = utils.create_livekit_client(self._config.server_configurations)
async with aiohttp.ClientSession(connector=connector) as session:
client = EgressService(session, **self._config.server_configurations)
method = getattr(client, method_name)
try:
response = await method(request)
except livekit_api.TwirpError as e:
raise WorkerConnectionError(
f"LiveKit client connection error, {e.message}."
) from e
# ruff: noqa: SLF001
# pylint: disable=protected-access
method = getattr(lkapi._egress, method_name)
try:
response = await method(request)
return response
except livekit_api.TwirpError as e:
raise WorkerConnectionError(
f"LiveKit client connection error, {e.message}."
) from e
except Exception as e:
raise WorkerConnectionError(
f"Unexpected error during LiveKit client connection: {str(e)}"
) from e
finally:
await lkapi.aclose()
def stop(self, worker_id: str) -> str:
"""Stop an ongoing egress worker.
+1 -2
View File
@@ -14,7 +14,6 @@ from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
LiveKitAPI,
SendDataRequest,
TwirpError,
)
@@ -347,7 +346,7 @@ class LobbyService:
"type": settings.LOBBY_NOTIFICATION_TYPE,
}
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
lkapi = utils.create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
@@ -58,7 +58,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
assert user.sub == "123"
assert user.email is None
assert user.password == "!"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -84,7 +84,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.email == email
assert user.full_name is None
assert user.short_name is None
assert user.password == "!"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -110,7 +110,7 @@ def test_authentication_getter_new_user_with_names(monkeypatch, email):
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.password == "!"
assert user.has_usable_password() is False
assert models.User.objects.count() == 1
@@ -129,7 +129,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
match="Claims verification failed",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
@@ -1,10 +0,0 @@
"""Unit tests for the Authentication URLs."""
from core.authentication.urls import urlpatterns
def test_urls_override_default_mozilla_django_oidc():
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
url_names = [u.name for u in urlpatterns]
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
@@ -1,359 +0,0 @@
"""Unit tests for the Authentication Views."""
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import crypto
import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import (
MozillaOIDCAuthenticationCallbackView,
OIDCAuthenticationCallbackView,
OIDCAuthenticationRequestView,
OIDCLogoutCallbackView,
OIDCLogoutView,
)
pytestmark = pytest.mark.django_db
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_anonymous():
"""Anonymous users calling the logout url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_custom")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
)
def test_view_logout(mocked_oidc_logout_url):
"""Authenticated users should be redirected to OIDC provider for logout."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
)
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
"""Authenticated users should be logged out when no OIDC provider is available."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/default-redirect-logout"
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback_anonymous():
"""Anonymous users calling the logout callback url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_callback")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize(
"initial_oidc_states",
[{}, {"other_state": "foo"}],
)
def test_view_logout_persist_state(initial_oidc_states):
"""State value should be persisted in session's data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_oidc_states:
request.session["oidc_states"] = initial_oidc_states
request.session.save()
mocked_state = "mock_state"
OIDCLogoutView().persist_state(request, mocked_state)
assert "oidc_states" in request.session
assert request.session["oidc_states"] == {
"mock_state": {},
**initial_oidc_states,
}
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
@mock.patch.object(OIDCLogoutView, "persist_state")
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
def test_view_logout_construct_oidc_logout_url(
mocked_get_random_string, mocked_persist_state
):
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["oidc_id_token"] = "mocked_oidc_id_token"
request.session.save()
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
mocked_persist_state.assert_called_once()
mocked_get_random_string.assert_called_once()
params = parse_qs(urlparse(redirect_url).query)
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
assert params["state"][0] == "mocked_state"
url = reverse("oidc_logout_callback")
assert url in params["post_logout_redirect_uri"][0]
@override_settings(LOGOUT_REDIRECT_URL="/")
def test_view_logout_construct_oidc_logout_url_none_id_token():
"""If no ID token is available in the session,
the user should be redirected to the final URL."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
assert redirect_url == "/"
@pytest.mark.parametrize(
"initial_state",
[None, {"other_state": "foo"}],
)
def test_view_logout_callback_wrong_state(initial_state):
"""Should raise an error if OIDC state doesn't match session data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_state:
request.session["oidc_states"] = initial_state
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with pytest.raises(SuspiciousOperation) as excinfo:
callback_view(request)
assert (
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
)
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback():
"""If state matches, callback should clear OIDC state and redirects."""
user = factories.UserFactory()
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
mocked_state = "mocked_state"
request.session["oidc_states"] = {mocked_state: {}}
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
def clear_user(request):
# Assert state is cleared prior to logout
assert request.session["oidc_states"] == {}
request.user = AnonymousUser()
mock_logout.side_effect = clear_user
response = callback_view(request)
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_default(settings, mocked_extra_params_setting):
"""By default, authentication request should not trigger silent login."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {}
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "foo"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
assert not request.session.get("silent")
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
"""If 'silent' parameter is set to True, the silent login should be triggered."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "true"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
expected_params = {"prompt": "none"}
assert (
extra_params == {**mocked_extra_params_setting, **expected_params}
if mocked_extra_params_setting
else expected_params
)
assert request.session.get("silent") is True
@mock.patch.object(
MozillaOIDCAuthenticationCallbackView,
"failure_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url(mocked_failure_url):
"""Test default behavior of the 'failure_url' property"""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_failure_url.assert_called_once()
assert returned_url == "foo"
@mock.patch.object(
OIDCAuthenticationCallbackView,
"success_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url_silent_login(mocked_success_url):
"""If a silent login was initiated and failed, it should not be treated as a failure."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["silent"] = True
request.session.save()
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_success_url.assert_called_once()
assert returned_url == "foo"
assert not request.session.get("silent")
@@ -4,6 +4,7 @@ Test event notification.
# pylint: disable=E1128,W0621,W0613,W0212
import datetime
import smtplib
from unittest import mock
@@ -74,6 +75,7 @@ def test_notify_external_services_unknown_mode(caplog):
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
# pylint: disable=too-many-locals
def test_notify_user_by_email_success(mocked_current_site, settings):
"""Test successful email notification to recording owners."""
settings.EMAIL_BRAND_NAME = "ACME"
@@ -84,15 +86,28 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
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]
# Fix recording test to avoid flaky tests
recording.created_at = datetime.datetime(2023, 5, 15, 14, 30, 0)
french_user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
dutch_user = factories.UserFactory(
email="berry@test.com", language="nl-nl", timezone="Europe/Amsterdam"
)
english_user = factories.UserFactory(
email="john@test.com", language="en-us", timezone="America/Phoenix"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=french_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=dutch_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=english_user
)
# Create non-owner users to verify they don't receive emails
factories.UserRecordingAccessFactory(
@@ -108,30 +123,69 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
result = notification_service._notify_user_by_email(recording)
assert result is True
mock_send_mail.assert_called_once()
assert mock_send_mail.call_count == 3
subject, body, sender, recipients = mock_send_mail.call_args[0]
call_args_list = mock_send_mail.call_args_list
assert subject == "Your recording is ready"
# Verify email contains expected content
required_content = [
base_content = [
"ACME", # Brand name
"support@acme.com", # Support email
"https://acme.com/logo", # Logo URL
f"https://acme.com/recordings/{recording.id}", # Recording link
"Conference Room A", # Room name
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
# First call verification
subject1, body1, sender1, recipients1 = call_args_list[0][0]
assert subject1 == "Votre enregistrement est prêt"
assert sender == "notifications@acme.com"
# Verify email contains expected content
personalized_content1 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Votre enregistrement est prêt !", # Intro
]
for content in personalized_content1:
assert content in body1
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(owner_emails)
assert recipients1 == ["franc@test.com"]
assert sender1 == "notifications@acme.com"
# Second call verification
subject2, body2, sender2, recipients2 = call_args_list[1][0]
assert subject2 == "Je opname is klaar"
# Verify second email content (if needed)
personalized_content2 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Je opname is klaar!", # Intro
]
for content in personalized_content2:
assert content in body2
assert recipients2 == ["berry@test.com"]
assert sender2 == "notifications@acme.com"
# Third call verification
subject3, body3, sender3, recipients3 = call_args_list[2][0]
assert subject3 == "Your recording is ready"
# Verify second email content (if needed)
personalized_content3 = [
*base_content,
"Conference Room A", # Room name
"2023-05-15", # Formatted date
"07:30", # Formatted time
"Your recording is ready!", # Intro
]
for content in personalized_content3:
assert content in body3
assert recipients3 == ["john@test.com"]
assert sender3 == "notifications@acme.com"
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
@@ -153,6 +207,9 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
notification_service = NotificationService()
@@ -163,5 +220,5 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
result = notification_service._notify_user_by_email(recording)
assert result is False
mock_send_mail.assert_called_once()
assert mock_send_mail.call_count == 2
assert "notification could not be sent:" in caplog.text
@@ -47,11 +47,12 @@ def test_api_recordings_list_authenticated_no_recording():
"role",
["administrator", "member", "owner"],
)
def test_api_recordings_list_authenticated_direct(role):
def test_api_recordings_list_authenticated_direct(role, settings):
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
"""
settings.RECORDING_EXPIRATIONS_DAYS = None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -89,6 +90,8 @@ def test_api_recordings_list_authenticated_direct(role):
},
"status": "initiated",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"expired_at": None,
"is_expired": False,
}
@@ -20,7 +20,7 @@ from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFac
pytestmark = pytest.mark.django_db
def test_api_documents_media_auth_unauthenticated():
def test_api_recordings_media_auth_unauthenticated():
"""
Test that unauthenticated requests to download media are rejected
"""
@@ -33,7 +33,7 @@ def test_api_documents_media_auth_unauthenticated():
assert response.status_code == 401
def test_api_documents_media_auth_wrong_path():
def test_api_recordings_media_auth_wrong_path():
"""
Test that media URLs with incorrect path structures are rejected
"""
@@ -51,7 +51,7 @@ def test_api_documents_media_auth_wrong_path():
assert response.status_code == 403
def test_api_documents_media_auth_unknown_recording():
def test_api_recordings_media_auth_unknown_recording():
"""
Test that requests for non-existent recordings are properly handled
"""
@@ -69,7 +69,7 @@ def test_api_documents_media_auth_unknown_recording():
assert response.status_code == 404
def test_api_documents_media_auth_no_abilities():
def test_api_recordings_media_auth_no_abilities():
"""
Test that users without any access permissions cannot download recordings
"""
@@ -88,7 +88,7 @@ def test_api_documents_media_auth_no_abilities():
assert response.status_code == 403
def test_api_documents_media_auth_wrong_abilities():
def test_api_recordings_media_auth_wrong_abilities():
"""
Test that users with insufficient role permissions cannot download recordings
"""
@@ -111,7 +111,7 @@ def test_api_documents_media_auth_wrong_abilities():
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
def test_api_documents_media_auth_unsaved(wrong_status):
def test_api_recordings_media_auth_unsaved(wrong_status):
"""
Test that recordings that aren't in 'saved' status cannot be downloaded
"""
@@ -132,7 +132,7 @@ def test_api_documents_media_auth_unsaved(wrong_status):
assert response.status_code == 403
def test_api_documents_media_auth_mismatched_extension():
def test_api_recordings_media_auth_mismatched_extension():
"""
Test that requests with mismatched file extensions are rejected
"""
@@ -160,7 +160,7 @@ def test_api_documents_media_auth_mismatched_extension():
@pytest.mark.parametrize(
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
)
def test_api_documents_media_auth_wrong_extension(wrong_extension):
def test_api_recordings_media_auth_wrong_extension(wrong_extension):
"""
Trying to download a recording with an unsupported extension should return
a validation error (400) with details about allowed extensions.
@@ -185,7 +185,7 @@ def test_api_documents_media_auth_wrong_extension(wrong_extension):
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_documents_media_auth_success_owner(mode):
def test_api_recordings_media_auth_success_owner(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
@@ -235,7 +235,7 @@ def test_api_documents_media_auth_success_owner(mode):
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_documents_media_auth_success_administrator(mode):
def test_api_recordings_media_auth_success_administrator(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
@@ -2,7 +2,10 @@
Test recordings API endpoints in the Meet core app: retrieve.
"""
import random
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
@@ -61,8 +64,9 @@ def test_api_recording_retrieve_members():
}
def test_api_recording_retrieve_administrators():
def test_api_recording_retrieve_administrators(settings):
"""A user who is an administrator of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
@@ -91,11 +95,14 @@ def test_api_recording_retrieve_administrators():
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
def test_api_recording_retrieve_owners():
def test_api_recording_retrieve_owners(settings):
"""A user who is an owner of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
@@ -123,6 +130,87 @@ def test_api_recording_retrieve_owners():
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
@freeze_time("2023-01-15 12:00:00")
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"""Test that the API returns the correct expiration date for a non-expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 1
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-16T12:00:00Z",
"is_expired": False, # Ensure the recording is still valid and hasn't expired
}
def test_api_recording_retrieve_expired(settings):
"""Test that the API returns the correct expiration date and flag for an expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 2
user = UserFactory()
with freeze_time("2023-01-15 12:00:00"):
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-17T12:00:00Z",
"is_expired": True, # Ensure the recording has expired
}
@@ -32,7 +32,6 @@ def test_settings():
mocked_settings = {
"RECORDING_OUTPUT_FOLDER": "/test/output",
"LIVEKIT_CONFIGURATION": {"server": "test.example.com"},
"RECORDING_VERIFY_SSL": True,
"AWS_S3_ENDPOINT_URL": "https://s3.test.com",
"AWS_S3_ACCESS_KEY_ID": "test_key",
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
@@ -56,7 +55,6 @@ def test_config_initialization(default_config):
"""Test that WorkerServiceConfig is properly initialized from settings"""
assert default_config.output_folder == "/test/output"
assert default_config.server_configurations == {"server": "test.example.com"}
assert default_config.verify_ssl is True
assert default_config.bucket_args == {
"endpoint": "https://s3.test.com",
"access_key": "test_key",
@@ -76,7 +74,6 @@ def test_config_immutability(default_config):
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
RECORDING_VERIFY_SSL=True,
AWS_S3_ENDPOINT_URL="https://s3.test.com",
AWS_S3_ACCESS_KEY_ID="test_key",
AWS_S3_SECRET_ACCESS_KEY="test_secret",
@@ -6,10 +6,9 @@ Test worker service classes.
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
import pytest
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
from core.recording.worker.exceptions import WorkerResponseError
from core.recording.worker.factories import WorkerServiceConfig
from core.recording.worker.services import (
AudioCompositeEgressService,
@@ -25,11 +24,10 @@ def config():
return WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"host": "test.livekit.io",
"url": "test.livekit.io",
"api_key": "test_key",
"api_secret": "test_secret",
},
verify_ssl=True,
bucket_args={
"endpoint": "https://s3.test.com",
"access_key": "test_key",
@@ -127,58 +125,6 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
assert result.endswith(f"{filename}.{extension}")
def test_base_egress_handle_request_success(
config, service, mock_client_session, mock_egress_service, mock_tcp_connector
):
"""Test successful request handling"""
# Setup mock response
mock_response = Mock()
mock_method = AsyncMock(return_value=mock_response)
mock_egress_instance = Mock()
mock_egress_instance.test_method = mock_method
mock_egress_service.return_value = mock_egress_instance
# Create test request
test_request = Mock()
response = service._handle_request(test_request, "test_method")
mock_client_session.assert_called_once_with(
connector=mock_tcp_connector.return_value
)
# Verify EgressService initialization
mock_egress_service.assert_called_once_with(
mock_client_session.return_value.__aenter__.return_value,
**service._config.server_configurations,
)
# Verify method call and response
mock_method.assert_called_once_with(test_request)
assert response == mock_response
def test_base_egress_handle_request_connection_error(service, mock_egress_service):
"""Test handling of connection errors"""
# Setup mock error
mock_method = AsyncMock(
side_effect=livekit_api.TwirpError(msg="Connection failed", code=500)
)
mock_egress_instance = Mock()
mock_egress_instance.test_method = mock_method
mock_egress_service.return_value = mock_egress_instance
# Create test request
test_request = Mock()
# Verify error handling
with pytest.raises(WorkerConnectionError) as exc:
service._handle_request(test_request, "test_method")
assert "LiveKit client connection error" in str(exc.value)
assert "Connection failed" in str(exc.value)
@pytest.mark.parametrize(
"response_status,expected_result",
[
@@ -224,43 +170,6 @@ def test_base_egress_start_not_implemented(service):
assert "Subclass must implement this method" in str(exc.value)
@pytest.mark.parametrize("verify_ssl", [True, False])
def test_base_egress_ssl_verification_config(verify_ssl):
"""Test SSL verification configuration"""
config = WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"host": "test.livekit.io",
"api_key": "test_key",
"api_secret": "test_secret",
},
verify_ssl=verify_ssl,
bucket_args={
"endpoint": "https://s3.test.com",
"access_key": "test_key",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
},
)
service = BaseEgressService(config)
# Mock ClientSession to capture connector configuration
with patch("aiohttp.ClientSession") as mock_session:
mock_session.return_value.__aenter__ = AsyncMock()
mock_session.return_value.__aexit__ = AsyncMock()
# Trigger request to verify connector configuration
service._handle_request(Mock(), "test_method")
# Verify SSL configuration
connector = mock_session.call_args[1]["connector"]
assert isinstance(connector, aiohttp.TCPConnector)
assert connector._ssl == verify_ssl
def test_video_composite_egress_hrid(video_service):
"""Test HRID is correct"""
assert video_service.hrid == "video-recording-composite-livekit-egress"
@@ -336,23 +336,22 @@ def test_api_rooms_retrieve_authenticated():
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
"""
Users who are members of a room should be allowed to see related users.
Users who are members of a room should not be allowed to see related users.
"""
settings.TIME_ZONE = "UTC"
user = UserFactory()
other_user = UserFactory()
room = RoomFactory()
user_access = UserResourceAccessFactory(resource=room, user=user, role="member")
other_user_access = UserResourceAccessFactory(
resource=room, user=other_user, role="member"
)
UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member")
client = APIClient()
client.force_login(user)
with django_assert_num_queries(4):
with django_assert_num_queries(3):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -360,33 +359,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
assert response.status_code == 200
content_dict = response.json()
assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": {
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
},
"resource": str(room.id),
"role": user_access.role,
},
{
"id": str(other_user_access.id),
"user": {
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
},
"resource": str(room.id),
"role": other_user_access.role,
},
],
key=lambda x: x["id"],
)
assert "accesses" not in content_dict
expected_name = str(room.id)
assert content_dict == {
@@ -415,11 +388,14 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries):
def test_api_rooms_retrieve_administrators(
mock_token, django_assert_num_queries, settings
):
"""
A user who is an administrator or owner of a room should be allowed
to see related users.
"""
settings.TIME_ZONE = "UTC"
user = UserFactory()
other_user = UserFactory()
room = RoomFactory()
@@ -448,6 +424,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
"timezone": "UTC",
"language": other_user_access.user.language,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -459,6 +437,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
"timezone": "UTC",
"language": user_access.user.language,
},
"resource": str(room.id),
"role": user_access.role,
+11 -20
View File
@@ -776,9 +776,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
@@ -794,15 +795,11 @@ def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Assert
# Verify the API was initialized with correct configuration
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
@@ -813,8 +810,8 @@ def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_success(mock_livekit_api, lobby_service):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
"""Test successful participant notification."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
@@ -830,14 +827,11 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
lobby_service.notify_participants(room.id)
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
@@ -855,15 +849,15 @@ def test_notify_participants_success(mock_livekit_api, lobby_service):
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.services.lobby.LiveKitAPI")
def test_notify_participants_error(mock_livekit_api, lobby_service):
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
"""Test participant notification with API error."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123)
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
@@ -874,7 +868,7 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_livekit_api.return_value = mock_api_instance
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(
@@ -882,9 +876,6 @@ def test_notify_participants_error(mock_livekit_api, lobby_service):
):
lobby_service.notify_participants(room.id)
# Verify the API was called correctly
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
+5 -1
View File
@@ -103,8 +103,10 @@ def test_api_users_retrieve_me_anonymous():
}
def test_api_users_retrieve_me_authenticated():
def test_api_users_retrieve_me_authenticated(settings):
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
settings.TIME_ZONE = "UTC"
user = factories.UserFactory()
client = APIClient()
@@ -121,6 +123,8 @@ def test_api_users_retrieve_me_authenticated():
"email": user.email,
"full_name": user.full_name,
"short_name": user.short_name,
"language": user.language,
"timezone": "UTC",
}
+62
View File
@@ -0,0 +1,62 @@
"""
Test utils functions
"""
from unittest import mock
from core.utils import create_livekit_client
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_ssl_enabled(
mock_livekit_api, mock_get_running_loop, settings
):
"""Test LiveKitAPI client creation with SSL verification enabled."""
mock_get_running_loop.return_value = mock.MagicMock()
settings.LIVEKIT_VERIFY_SSL = True
create_livekit_client()
mock_livekit_api.assert_called_once_with(
**settings.LIVEKIT_CONFIGURATION, session=None
)
@mock.patch("core.utils.aiohttp.ClientSession")
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_ssl_disabled(
mock_livekit_api, mock_get_running_loop, mock_client_session, settings
):
"""Test LiveKitAPI client creation with SSL verification disabled."""
mock_get_running_loop.return_value = mock.MagicMock()
mock_session_instance = mock.MagicMock()
mock_client_session.return_value = mock_session_instance
settings.LIVEKIT_VERIFY_SSL = False
create_livekit_client()
mock_livekit_api.assert_called_once_with(
**settings.LIVEKIT_CONFIGURATION, session=mock_session_instance
)
@mock.patch("asyncio.get_running_loop")
@mock.patch("core.utils.LiveKitAPI")
def test_create_livekit_client_custom_configuration(
mock_livekit_api, mock_get_running_loop, settings
):
"""Test LiveKitAPI client creation with custom configuration."""
settings.LIVEKIT_VERIFY_SSL = True
mock_get_running_loop.return_value = mock.MagicMock()
custom_configuration = {
"api_key": "mock_key",
"api_secret": "mock_secret",
"url": "http://mock-url.com",
}
create_livekit_client(custom_configuration)
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
+1 -1
View File
@@ -3,10 +3,10 @@
from django.conf import settings
from django.urls import include, path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets
from core.authentication.urls import urlpatterns as oidc_urls
# - Main endpoints
router = DefaultRouter()
+17 -1
View File
@@ -13,8 +13,9 @@ from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
import aiohttp
import botocore
from livekit.api import AccessToken, VideoGrants
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
def generate_color(identity: str) -> str:
@@ -142,3 +143,18 @@ def generate_s3_authorization_headers(key):
auth.add_auth(request)
return request
def create_livekit_client(custom_configuration=None):
"""Create and return a configured LiveKit API client."""
custom_session = None
if not settings.LIVEKIT_VERIFY_SSL:
connector = aiohttp.TCPConnector(ssl=False)
custom_session = aiohttp.ClientSession(connector=connector)
# Use default configuration if none provided
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
return LiveKitAPI(session=custom_session, **configuration)
Binary file not shown.
@@ -0,0 +1,430 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-13 13:54+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persönliche Informationen"
#: core/admin.py:39
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/models.py:31
msgid "Member"
msgstr "Mitglied"
#: core/models.py:32
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:33
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:49
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:50
msgid "Active"
msgstr "Aktiv"
#: core/models.py:51
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:52
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:53
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:90
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:104
msgid "id"
msgstr "ID"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:111
msgid "created on"
msgstr "erstellt am"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:117
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:144
msgid "sub"
msgstr "Sub"
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
#: core/models.py:154
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:159
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:161
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:163
msgid "short name"
msgstr "Kurzname"
#: core/models.py:169
msgid "language"
msgstr "Sprache"
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:179
msgid "device"
msgstr "Gerät"
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:184
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:189
msgid "active"
msgstr "aktiv"
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:205
msgid "user"
msgstr "Benutzer"
#: core/models.py:206
msgid "users"
msgstr "Benutzer"
#: core/models.py:265
msgid "Resource"
msgstr "Ressource"
#: core/models.py:266
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:320
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:321
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:390 core/models.py:510
msgid "Room"
msgstr "Raum"
#: core/models.py:391
msgid "Rooms"
msgstr "Räume"
#: core/models.py:521
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:523
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Geben Sie eine ID für die Aufzeichnung des Workers ein. Diese ID bleibt "
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:531
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:532
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:538
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:539
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:647
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:648
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:654
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:660
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:666
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "AM ANRUF TEILNEHMEN"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen "
"Sie sie in Ihren Browser ein, um am Anruf teilzunehmen."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tipps für ein besseres Erlebnis:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Ihre Aufzeichnung ist fertig!"
#: 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 ""
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
"%(recording_time)s steht nun zum Herunterladen bereit. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
#: 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 "Speichern Sie die Datei an einem gewünschten Ort"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Öffnen"
#: 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 ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "Englisch"
#: meet/settings.py:163
msgid "French"
msgstr "Französisch"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:165
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+213 -256
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-14 19:04+0000\n"
"POT-Creation-Date: 2025-05-13 13:54+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"
@@ -23,390 +23,339 @@ msgstr ""
#: core/admin.py:39
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:51
msgid "Important dates"
msgstr ""
msgstr "Important dates"
#: core/api/serializers.py:60
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr ""
#: 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
#: core/models.py:31
msgid "Member"
msgstr ""
msgstr "Member"
#: core/models.py:28
#: core/models.py:32
msgid "Administrator"
msgstr ""
msgstr "Administrator"
#: core/models.py:29
#: core/models.py:33
msgid "Owner"
msgstr ""
#: core/models.py:45
msgid "Initiated"
msgstr ""
#: core/models.py:46
msgid "Active"
msgstr ""
#: core/models.py:47
msgid "Stopped"
msgstr ""
#: core/models.py:48
msgid "Saved"
msgstr ""
msgstr "Owner"
#: core/models.py:49
msgid "Aborted"
msgstr ""
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:50
msgid "Failed to Start"
msgstr ""
msgid "Active"
msgstr "Active"
#: core/models.py:51
msgid "Failed to Stop"
msgstr ""
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:52
msgid "Saved"
msgstr "Saved"
#: core/models.py:53
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:56
msgid "Notification succeeded"
msgstr ""
msgstr "Notification succeeded"
#: core/models.py:79
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr ""
msgstr "SCREEN_RECORDING"
#: core/models.py:80
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr ""
msgstr "TRANSCRIPT"
#: core/models.py:86
#: core/models.py:90
msgid "Public Access"
msgstr ""
msgstr "Public Access"
#: core/models.py:87
#: core/models.py:91
msgid "Trusted Access"
msgstr ""
msgstr "Trusted Access"
#: core/models.py:88
#: core/models.py:92
msgid "Restricted Access"
msgstr ""
msgstr "Restricted Access"
#: core/models.py:100
#: core/models.py:104
msgid "id"
msgstr ""
msgstr "id"
#: core/models.py:101
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr ""
msgstr "primary key for the record as UUID"
#: core/models.py:107
#: core/models.py:111
msgid "created on"
msgstr ""
msgstr "created on"
#: core/models.py:108
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr ""
msgstr "date and time at which a record was created"
#: core/models.py:113
#: core/models.py:117
msgid "updated on"
msgstr ""
msgstr "updated on"
#: core/models.py:114
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr ""
msgstr "date and time at which a record was last updated"
#: core/models.py:134
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:140
#: core/models.py:144
msgid "sub"
msgstr ""
msgstr "sub"
#: core/models.py:142
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:150
#: core/models.py:154
msgid "identity email address"
msgstr ""
#: core/models.py:155
msgid "admin email address"
msgstr ""
#: core/models.py:157
msgid "full name"
msgstr ""
msgstr "identity email address"
#: core/models.py:159
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:161
msgid "full name"
msgstr "full name"
#: core/models.py:163
msgid "short name"
msgstr ""
msgstr "short name"
#: core/models.py:165
#: core/models.py:169
msgid "language"
msgstr ""
msgstr "language"
#: core/models.py:166
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "The language in which the user wants to see the interface."
#: core/models.py:172
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "The timezone in which the user wants to see times."
#: core/models.py:175
#: core/models.py:179
msgid "device"
msgstr ""
msgstr "device"
#: core/models.py:177
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Whether the user is a device or a real user."
#: core/models.py:180
#: core/models.py:184
msgid "staff status"
msgstr ""
msgstr "staff status"
#: core/models.py:182
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Whether the user can log into this admin site."
#: core/models.py:185
#: core/models.py:189
msgid "active"
msgstr ""
msgstr "active"
#: core/models.py:188
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:201
#: core/models.py:205
msgid "user"
msgstr ""
msgstr "user"
#: core/models.py:202
#: core/models.py:206
msgid "users"
msgstr ""
msgstr "users"
#: core/models.py:261
#: core/models.py:265
msgid "Resource"
msgstr ""
msgstr "Resource"
#: core/models.py:262
#: core/models.py:266
msgid "Resources"
msgstr ""
msgstr "Resources"
#: core/models.py:316
#: core/models.py:320
msgid "Resource access"
msgstr ""
msgstr "Resource access"
#: core/models.py:317
#: core/models.py:321
msgid "Resource accesses"
msgstr ""
msgstr "Resource accesses"
#: core/models.py:323
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:379
#: core/models.py:383
msgid "Visio room configuration"
msgstr ""
msgstr "Visio room configuration"
#: core/models.py:380
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr ""
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:386 core/models.py:506
#: core/models.py:390 core/models.py:510
msgid "Room"
msgstr ""
msgstr "Room"
#: core/models.py:387
#: core/models.py:391
msgid "Rooms"
msgstr ""
msgstr "Rooms"
#: core/models.py:517
#: core/models.py:521
msgid "Worker ID"
msgstr ""
msgstr "Worker ID"
#: core/models.py:519
#: core/models.py:523
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:527
#: core/models.py:531
msgid "Recording mode"
msgstr ""
msgstr "Recording mode"
#: core/models.py:528
#: core/models.py:532
msgid "Defines the mode of recording being called."
msgstr ""
msgstr "Defines the mode of recording being called."
#: core/models.py:534
#: core/models.py:538
msgid "Recording"
msgstr ""
msgstr "Recording"
#: core/models.py:535
#: core/models.py:539
msgid "Recordings"
msgstr ""
msgstr "Recordings"
#: core/models.py:592
#: core/models.py:647
msgid "Recording/user relation"
msgstr ""
msgstr "Recording/user relation"
#: core/models.py:593
#: core/models.py:648
msgid "Recording/user relations"
msgstr ""
msgstr "Recording/user relations"
#: core/models.py:599
#: core/models.py:654
msgid "This user is already in this recording."
msgstr ""
msgstr "This user is already in this recording."
#: core/models.py:605
#: core/models.py:660
msgid "This team is already in this recording."
msgstr ""
msgstr "This team is already in this recording."
#: core/models.py:611
#: core/models.py:666
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Either user or team must be set, not both."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr ""
#: 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
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr ""
msgstr "Your recording is ready"
#: core/templates/mail/html/invitation.html:160
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
msgid "invites you to join an ongoing video call"
msgstr "invites you to join an ongoing video call"
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Meet</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "JOIN THE CALL"
#: core/templates/mail/html/invitation.html: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
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"We are delighted to welcome you to our community on Meet, your new companion "
"to collaborate on documents efficiently, intuitively, and securely."
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/html/invitation.html:235
#: 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 ""
msgid "Tips for a better experience:"
msgstr "Tips for a better experience:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create documents."
msgstr ""
msgid "Use Chrome or Firefox for better call quality"
msgstr "Use Chrome or Firefox for better call quality"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your document or community in just a few clicks."
msgstr ""
msgid "Test your microphone and camera before joining"
msgstr "Test your microphone and camera before joining"
#: core/templates/mail/html/invitation.html:249
#: core/templates/mail/text/invitation.txt:20
msgid "Visit Meet"
msgstr ""
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection"
#: core/templates/mail/html/invitation.html: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/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr ""
msgstr "Your recording is ready!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
@@ -415,31 +364,39 @@ msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " The recording will expire in %(days)s days. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr ""
msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \\"
msgstr ""
msgid "Click the \"Open\" button below "
msgstr "Click the \"Open\" button below "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \\"
msgstr ""
msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr ""
msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr ""
msgstr "Open"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
@@ -448,21 +405,21 @@ 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 ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "English"
#: meet/settings.py:163
msgid "French"
msgstr ""
msgstr "French"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:165
msgid "German"
msgstr "German"
Binary file not shown.
+218 -255
View File
@@ -8,9 +8,9 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-14 19:04+0000\n"
"POT-Creation-Date: 2025-05-13 13:54+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
@@ -19,394 +19,349 @@ msgstr ""
#: core/admin.py:26
msgid "Personal info"
msgstr ""
msgstr "Informations personnelles"
#: core/admin.py:39
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: core/admin.py:51
msgid "Important dates"
msgstr ""
msgstr "Dates importantes"
#: core/api/serializers.py:60
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr ""
#: 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
#: core/models.py:31
msgid "Member"
msgstr ""
msgstr "Membre"
#: core/models.py:28
#: core/models.py:32
msgid "Administrator"
msgstr ""
msgstr "Administrateur"
#: core/models.py:29
#: core/models.py:33
msgid "Owner"
msgstr ""
#: core/models.py:45
msgid "Initiated"
msgstr ""
#: core/models.py:46
msgid "Active"
msgstr ""
#: core/models.py:47
msgid "Stopped"
msgstr ""
#: core/models.py:48
msgid "Saved"
msgstr ""
msgstr "Propriétaire"
#: core/models.py:49
msgid "Aborted"
msgstr ""
msgid "Initiated"
msgstr "Initié"
#: core/models.py:50
msgid "Failed to Start"
msgstr ""
msgid "Active"
msgstr "Actif"
#: core/models.py:51
msgid "Failed to Stop"
msgstr ""
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:52
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:53
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:56
msgid "Notification succeeded"
msgstr ""
msgstr "Notification réussie"
#: core/models.py:79
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr ""
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:80
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr ""
msgstr "TRANSCRIPTION"
#: core/models.py:86
#: core/models.py:90
msgid "Public Access"
msgstr ""
msgstr "Accès public"
#: core/models.py:87
#: core/models.py:91
msgid "Trusted Access"
msgstr ""
msgstr "Accès de confiance"
#: core/models.py:88
#: core/models.py:92
msgid "Restricted Access"
msgstr ""
msgstr "Accès restreint"
#: core/models.py:100
#: core/models.py:104
msgid "id"
msgstr ""
msgstr "id"
#: core/models.py:101
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr ""
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:107
#: core/models.py:111
msgid "created on"
msgstr ""
msgstr "créé le"
#: core/models.py:108
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr ""
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:113
#: core/models.py:117
msgid "updated on"
msgstr ""
msgstr "mis à jour le"
#: core/models.py:114
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:134
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez un identifiant valide. Cette valeur ne peut contenir que des lettres, "
"des chiffres et les caractères @/./+/-/_."
#: core/models.py:140
#: core/models.py:144
msgid "sub"
msgstr ""
msgstr "identifiant"
#: core/models.py:142
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:150
#: core/models.py:154
msgid "identity email address"
msgstr ""
#: core/models.py:155
msgid "admin email address"
msgstr ""
#: core/models.py:157
msgid "full name"
msgstr ""
msgstr "adresse e-mail d'identité"
#: core/models.py:159
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:161
msgid "full name"
msgstr "nom complet"
#: core/models.py:163
msgid "short name"
msgstr ""
msgstr "nom court"
#: core/models.py:165
#: core/models.py:169
msgid "language"
msgstr ""
msgstr "langue"
#: core/models.py:166
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr ""
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:172
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr ""
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:175
#: core/models.py:179
msgid "device"
msgstr ""
msgstr "appareil"
#: core/models.py:177
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr ""
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:180
#: core/models.py:184
msgid "staff status"
msgstr ""
msgstr "statut du personnel"
#: core/models.py:182
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr ""
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:185
#: core/models.py:189
msgid "active"
msgstr ""
msgstr "actif"
#: core/models.py:188
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:201
#: core/models.py:205
msgid "user"
msgstr ""
msgstr "utilisateur"
#: core/models.py:202
#: core/models.py:206
msgid "users"
msgstr ""
msgstr "utilisateurs"
#: core/models.py:261
#: core/models.py:265
msgid "Resource"
msgstr ""
msgstr "Ressource"
#: core/models.py:262
#: core/models.py:266
msgid "Resources"
msgstr ""
msgstr "Ressources"
#: core/models.py:316
#: core/models.py:320
msgid "Resource access"
msgstr ""
msgstr "Accès aux ressources"
#: core/models.py:317
#: core/models.py:321
msgid "Resource accesses"
msgstr ""
msgstr "Accès aux ressources"
#: core/models.py:323
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:379
#: core/models.py:383
msgid "Visio room configuration"
msgstr ""
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:380
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr ""
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:386 core/models.py:506
#: core/models.py:390 core/models.py:510
msgid "Room"
msgstr ""
msgstr "Salle"
#: core/models.py:387
#: core/models.py:391
msgid "Rooms"
msgstr ""
msgstr "Salles"
#: core/models.py:517
#: core/models.py:521
msgid "Worker ID"
msgstr ""
msgstr "ID du Worker"
#: core/models.py:519
#: core/models.py:523
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Entrez un identifiant pour l'enregistrement du travailleur. Cet identifiant "
"est conservé même lorsque le travailleur s'arrête, permettant un suivi "
"facile."
#: core/models.py:527
#: core/models.py:531
msgid "Recording mode"
msgstr ""
msgstr "Mode d'enregistrement"
#: core/models.py:528
#: core/models.py:532
msgid "Defines the mode of recording being called."
msgstr ""
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:534
#: core/models.py:538
msgid "Recording"
msgstr ""
msgstr "Enregistrement"
#: core/models.py:535
#: core/models.py:539
msgid "Recordings"
msgstr ""
msgstr "Enregistrements"
#: core/models.py:592
#: core/models.py:647
msgid "Recording/user relation"
msgstr ""
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:593
#: core/models.py:648
msgid "Recording/user relations"
msgstr ""
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:599
#: core/models.py:654
msgid "This user is already in this recording."
msgstr ""
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:605
#: core/models.py:660
msgid "This team is already in this recording."
msgstr ""
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:611
#: core/models.py:666
msgid "Either user or team must be set, not both."
msgstr ""
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr ""
#: 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
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr ""
msgstr "Votre enregistrement est prêt"
#: core/templates/mail/html/invitation.html:160
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
msgid "La Suite Numérique"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo email"
#: core/templates/mail/html/invitation.html:190
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "Invitation to join a team"
msgstr ""
msgid "invites you to join an ongoing video call"
msgstr "vous invite à rejoindre un appel vidéo en cours"
#: core/templates/mail/html/invitation.html:198
msgid "Welcome to <strong>Meet</strong>"
msgstr ""
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "REJOINDRE L'APPEL"
#: core/templates/mail/html/invitation.html: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
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"We are delighted to welcome you to our community on Meet, your new companion "
"to collaborate on documents efficiently, intuitively, and securely."
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
"votre navigateur pour rejoindre l'appel."
#: core/templates/mail/html/invitation.html:231
#: core/templates/mail/html/invitation.html:235
#: 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 ""
msgid "Tips for a better experience:"
msgstr "Conseils pour une meilleure expérience :"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Create documents."
msgstr ""
msgid "Use Chrome or Firefox for better call quality"
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Invite members of your document or community in just a few clicks."
msgstr ""
msgid "Test your microphone and camera before joining"
msgstr "Testez votre microphone et votre caméra avant de rejoindre"
#: core/templates/mail/html/invitation.html:249
#: core/templates/mail/text/invitation.txt:20
msgid "Visit Meet"
msgstr ""
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: core/templates/mail/html/invitation.html: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/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr ""
msgstr "Votre enregistrement est prêt !"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
@@ -415,31 +370,39 @@ msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
"%(recording_time)s est maintenant prêt à être téléchargé. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " L'enregistrement expirera dans %(days)s jours. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr ""
msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \\"
msgstr ""
msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \\"
msgstr ""
msgid "Use the \"Download\" button in the interface "
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr ""
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr ""
msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
@@ -448,21 +411,21 @@ 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 ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "Anglais"
#: meet/settings.py:163
msgid "French"
msgstr ""
msgstr "Français"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:165
msgid "German"
msgstr "Allemand"
Binary file not shown.
@@ -0,0 +1,425 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-13 13:54+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: core/admin.py:39
msgid "Permissions"
msgstr "Rechten"
#: core/admin.py:51
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/models.py:31
msgid "Member"
msgstr "Lid"
#: core/models.py:32
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:33
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:49
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:50
msgid "Active"
msgstr "Actief"
#: core/models.py:51
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:52
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:53
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:111
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:117
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:144
msgid "sub"
msgstr "sub"
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
#: core/models.py:154
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:159
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:161
msgid "full name"
msgstr "volledige naam"
#: core/models.py:163
msgid "short name"
msgstr "korte naam"
#: core/models.py:169
msgid "language"
msgstr "taal"
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:179
msgid "device"
msgstr "apparaat"
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:184
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:189
msgid "active"
msgstr "actief"
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:205
msgid "user"
msgstr "gebruiker"
#: core/models.py:206
msgid "users"
msgstr "gebruikers"
#: core/models.py:265
msgid "Resource"
msgstr "Bron"
#: core/models.py:266
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:320
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:321
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:390 core/models.py:510
msgid "Room"
msgstr "Ruimte"
#: core/models.py:391
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:521
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:523
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:531
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:532
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:538
msgid "Recording"
msgstr "Opname"
#: core/models.py:539
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:647
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:648
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:654
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:660
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:666
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "NEEM DEEL AAN DE OPROEP"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je "
"browser om deel te nemen aan de oproep."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tips voor een betere ervaring:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Test je microfoon en camera voordat je deelneemt"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Je opname is klaar!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s "
"is nu klaar om te downloaden. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " De opname verloopt over %(days)s dagen. "
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Klik op de \"Openen\"-knop hieronder "
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:162
msgid "English"
msgstr "Engels"
#: meet/settings.py:163
msgid "French"
msgstr "Frans"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:165
msgid "German"
msgstr "Duits"
+28 -6
View File
@@ -23,7 +23,6 @@ from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join("/", "data")
def get_release():
@@ -70,6 +69,8 @@ class Base(Configuration):
API_VERSION = "v1.0"
DATA_DIR = values.Value(os.path.join("/", "data"), environ_name="DATA_DIR")
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = values.Value(None)
@@ -160,6 +161,8 @@ class Base(Configuration):
(
("en-us", _("English")),
("fr-fr", _("French")),
("nl-nl", _("Dutch")),
("de-de", _("German")),
)
)
@@ -261,6 +264,9 @@ class Base(Configuration):
"rest_framework.parsers.JSONParser",
"nested_multipart_parser.drf.DrfNestedParser",
],
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
"EXCEPTION_HANDLER": "core.api.exception_handler",
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
@@ -310,6 +316,9 @@ class Base(Configuration):
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
),
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
}
# Mail
@@ -354,8 +363,8 @@ class Base(Configuration):
SESSION_COOKIE_AGE = 60 * 60 * 12
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationCallbackView"
OIDC_CREATE_USER = values.BooleanValue(
default=True, environ_name="OIDC_CREATE_USER", environ_prefix=None
)
@@ -389,6 +398,9 @@ class Base(Configuration):
OIDC_OP_USER_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
)
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
)
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
)
@@ -435,6 +447,11 @@ class Base(Configuration):
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None,
)
OIDC_USERINFO_ESSENTIAL_CLAIMS = values.ListValue(
default=[],
environ_name="OIDC_USERINFO_ESSENTIAL_CLAIMS",
environ_prefix=None,
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
@@ -444,6 +461,9 @@ class Base(Configuration):
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
)
@@ -458,9 +478,6 @@ class Base(Configuration):
RECORDING_OUTPUT_FOLDER = values.Value(
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
)
RECORDING_VERIFY_SSL = values.BooleanValue(
True, environ_name="RECORDING_VERIFY_SSL", environ_prefix=None
)
RECORDING_WORKER_CLASSES = values.DictValue(
{
"screen_recording": "core.recording.worker.services.VideoCompositeEgressService",
@@ -483,6 +500,11 @@ class Base(Configuration):
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
)
# Number of days before recordings expire - must be synced with bucket lifecycle policy
# Set to None for no expiration
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
+4 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.18"
version = "0.1.22"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -32,12 +32,13 @@ dependencies = [
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-lasuite==0.0.7",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.5",
"django-timezone-field>=5.1",
"django==5.1.8",
"django==5.1.9",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
@@ -55,7 +56,7 @@ dependencies = [
"sentry-sdk==2.24.1",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==0.8.2",
"livekit-api==1.0.2",
"aiohttp==3.11.14",
]
+1374 -1336
View File
File diff suppressed because it is too large Load Diff
+25 -25
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.18",
"version": "0.1.22",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,48 +13,48 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.3",
"@pandacss/preset-panda": "0.53.3",
"@react-aria/toast": "3.0.1",
"@livekit/components-react": "2.9.3",
"@livekit/components-styles": "1.1.5",
"@livekit/track-processors": "0.5.6",
"@pandacss/preset-panda": "0.53.6",
"@react-aria/toast": "3.0.2",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.69.0",
"@tanstack/react-query": "5.76.0",
"crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3",
"i18next": "24.2.3",
"i18next-browser-languagedetector": "8.0.4",
"i18next": "25.1.2",
"i18next-browser-languagedetector": "8.1.0",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.9.8",
"posthog-js": "1.232.6",
"livekit-client": "2.11.4",
"posthog-js": "1.240.6",
"react": "18.3.1",
"react-aria-components": "1.7.1",
"react-aria-components": "1.8.0",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.1.4",
"wouter": "3.6.0"
"valtio": "2.1.5",
"wouter": "3.7.0"
},
"devDependencies": {
"@pandacss/dev": "0.53.3",
"@tanstack/eslint-plugin-query": "5.68.0",
"@tanstack/react-query-devtools": "5.69.0",
"@types/node": "22.13.13",
"@pandacss/dev": "0.53.6",
"@tanstack/eslint-plugin-query": "5.74.7",
"@tanstack/react-query-devtools": "5.76.0",
"@types/node": "22.15.17",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.28.0",
"@typescript-eslint/parser": "8.28.0",
"@vitejs/plugin-react": "4.3.4",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"eslint": "8.57.0",
"eslint-config-prettier": "10.1.1",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.19",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.3",
"prettier": "3.5.3",
"typescript": "5.8.2",
"vite": "6.2.4",
"typescript": "5.8.3",
"vite": "6.2.7",
"vite-tsconfig-paths": "5.1.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+4 -1
View File
@@ -13,14 +13,17 @@ import { routes } from './routes'
import './i18n/init'
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
const isSDKContext = useIsSdkContext()
return (
<QueryClientProvider client={queryClient}>
<AppInitialization />
{!isSDKContext && <AppInitialization />}
<Suspense fallback={null}>
<I18nProvider locale={i18n.language}>
<Layout>
+4
View File
@@ -11,11 +11,15 @@ export interface ApiConfig {
support?: {
id: string
}
feedback: {
url: string
}
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
recording?: {
is_enabled?: boolean
available_modes?: RecordingMode[]
expiration_days?: number
}
}
@@ -2,13 +2,9 @@ import { silenceLiveKitLogs } from '@/utils/livekit'
import { useConfig } from '@/api/useConfig'
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
import { useSupport } from '@/features/support/hooks/useSupport'
import { useLocation } from 'wouter'
const SDK_BASE_ROUTE = '/sdk'
export const AppInitialization = () => {
const { data } = useConfig()
const [location] = useLocation()
const {
analytics = {},
@@ -16,10 +12,8 @@ export const AppInitialization = () => {
silence_livekit_debug_logs = false,
} = data || {}
const isSDKContext = location.includes(SDK_BASE_ROUTE)
useAnalytics({ ...analytics, isDisabled: isSDKContext })
useSupport({ ...support, isDisabled: isSDKContext })
useAnalytics(analytics)
useSupport(support)
silenceLiveKitLogs(silence_livekit_debug_logs)
@@ -2,10 +2,14 @@ import { css } from '@/styled-system/css'
import { RiErrorWarningLine, RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { Text, A } from '@/primitives'
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
import { useConfig } from '@/api/useConfig'
export const FeedbackBanner = () => {
const { t } = useTranslation()
const { data } = useConfig()
if (!data?.feedback?.url) return
return (
<div
className={css({
@@ -35,7 +39,7 @@ export const FeedbackBanner = () => {
gap: 0.25,
})}
>
<A href={GRIST_FEEDBACKS_FORM} target="_blank" size="sm">
<A href={data?.feedback?.url} target="_blank" size="sm">
{t('feedback.cta')}
</A>
<RiExternalLinkLine size={16} aria-hidden="true" />
@@ -1,6 +1,10 @@
import { BackendLanguage } from '@/utils/languages'
export type ApiUser = {
id: string
email: string
full_name: string
last_name: string
language: BackendLanguage
timezone: string
}
@@ -26,7 +26,7 @@ export const fetchUser = (
// make sure to not resolve the promise while trying to silent login
// so that consumers of fetchUser don't think the work already ended
if (opts.attemptSilent && canAttemptSilentLogin()) {
attemptSilentLogin(300)
attemptSilentLogin(30)
} else {
resolve(false)
}
@@ -0,0 +1,15 @@
import { type ApiUser } from './ApiUser'
import { fetchApi } from '@/api/fetchApi'
export type ApiUserPreferences = Pick<ApiUser, 'id' | 'timezone' | 'language'>
export const updateUserPreferences = async ({
user,
}: {
user: ApiUserPreferences
}): Promise<ApiUser> => {
return await fetchApi(`/users/${user.id}/`, {
method: 'PUT',
body: JSON.stringify({ timezone: user.timezone, language: user.language }),
})
}
@@ -0,0 +1,47 @@
import { useMutation } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { queryClient } from '@/api/queryClient'
import { updateUserPreferences } from './updateUserPreferences'
import { convertToBackendLanguage } from '@/utils/languages'
import { useUser } from './useUser'
/**
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
* Automatically updates backend when browser settings change for logged-in users.
*/
export const useSyncUserPreferencesWithBackend = () => {
const { i18n } = useTranslation()
const { user, isLoggedIn } = useUser()
const { mutateAsync } = useMutation({
mutationFn: updateUserPreferences,
onSuccess: (updatedUser) => {
queryClient.setQueryData([keys.user], updatedUser)
},
})
useEffect(() => {
if (!user || !isLoggedIn) return
const syncBrowserPreferencesToBackend = async () => {
const currentTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const currentLanguage = convertToBackendLanguage(i18n.language)
if (
currentLanguage !== user.language ||
currentTimezone !== user.timezone
) {
await mutateAsync({
user: {
id: user.id,
timezone: currentTimezone,
language: currentLanguage,
},
})
}
}
syncBrowserPreferencesToBackend()
}, [i18n.language, isLoggedIn, user, mutateAsync])
}
@@ -24,24 +24,25 @@ export const useUser = (
fetchUserOptions?: Parameters<typeof fetchUser>[0]
} = {}
) => {
const { data, isLoading } = useConfig()
const { data, isLoading: isConfigLoading } = useConfig()
const options = useMemo(() => {
if (!data || data?.is_silent_login_enabled !== true) {
if (isConfigLoading) return
if (data?.is_silent_login_enabled !== true) {
return {
...opts,
attemptSilent: false,
}
}
return opts.fetchUserOptions
}, [data, opts])
}, [data, opts, isConfigLoading])
const query = useQuery({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: [keys.user],
queryFn: () => fetchUser(options),
staleTime: Infinity,
enabled: !isLoading,
enabled: !isConfigLoading,
})
useEffect(() => {
+1
View File
@@ -1,3 +1,4 @@
export { useUser } from './api/useUser'
export { useSyncUserPreferencesWithBackend } from './api/useSyncUserPreferencesWithBackend'
export { authUrl } from './utils/authUrl'
export { UserAware } from './components/UserAware'
@@ -11,7 +11,7 @@ import { useNotificationSound } from '@/features/notifications/hooks/useSoundNot
import { ToastProvider, toastQueue } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
import {
EMOJIS,
Emoji,
Reaction,
} from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import {
@@ -50,7 +50,7 @@ export const MainNotificationToast = () => {
}, [room, triggerNotificationSound])
const handleEmoji = (emoji: string, participant: Participant) => {
if (!emoji || !EMOJIS.includes(emoji)) return
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
const id = instanceIdRef.current++
setReactions((prev) => [
...prev,
@@ -9,6 +9,8 @@ export type RecordingApi = {
key: string
mode: RecordingMode
status: RecordingStatus
is_expired: boolean
expired_at: string
}
export const fetchRecording = ({ recordingId }: { recordingId?: string }) => {
@@ -27,5 +27,6 @@ export function useStartRecording(
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
mutationFn: startRecording,
onSuccess: options?.onSuccess,
onError: options?.onError,
})
}
@@ -19,5 +19,6 @@ export function useStopRecording(
return useMutation<ApiRoom, ApiError, StopRecordingParams>({
mutationFn: stopRecording,
onSuccess: options?.onSuccess,
onError: options?.onError,
})
}
@@ -1,4 +1,4 @@
import { A, Button, Div, H, Text } from '@/primitives'
import { A, Button, Dialog, Div, H, P, Text } from '@/primitives'
import fourthSlide from '@/assets/intro-slider/4_record.png'
import { css } from '@/styled-system/css'
@@ -6,19 +6,19 @@ import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
import { useRoomContext } from '@livekit/components-react'
import {
RecordingMode,
useIsRecordingTransitioning,
useStartRecording,
useStopRecording,
} from '@/features/recording'
import { useEffect, useMemo, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import { CRISP_HELP_ARTICLE_RECORDING } from '@/utils/constants'
import { useIsRecordingTransitioning } from '@/features/recording'
import {
useNotifyParticipants,
NotificationType,
useNotifyParticipants,
} from '@/features/notifications'
import posthog from 'posthog-js'
import { useSnapshot } from 'valtio/index'
@@ -29,12 +29,20 @@ export const ScreenRecordingSidePanel = () => {
const recordingSnap = useSnapshot(recordingStore)
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
const { notifyParticipants } = useNotifyParticipants()
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
useStartRecording({
onError: () => setIsErrorDialogOpen('start'),
})
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
useStopRecording({
onError: () => setIsErrorDialogOpen('stop'),
})
const statuses = useMemo(() => {
return {
@@ -50,6 +58,7 @@ export const ScreenRecordingSidePanel = () => {
}, [recordingSnap])
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
const isRecordingTransitioning = useIsRecordingTransitioning()
useEffect(() => {
@@ -94,8 +103,11 @@ export const ScreenRecordingSidePanel = () => {
const isDisabled = useMemo(
() =>
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
[isLoading, isRecordingTransitioning, statuses]
isLoading ||
isRecordingTransitioning ||
statuses.isAnotherModeStarted ||
!isRoomConnected,
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
)
return (
@@ -145,7 +157,7 @@ export const ScreenRecordingSidePanel = () => {
</>
) : (
<>
{statuses.isStopping ? (
{statuses.isStopping || isPendingToStop ? (
<>
<H lvl={3} margin={false}>
{t('stopping.heading')}
@@ -193,7 +205,7 @@ export const ScreenRecordingSidePanel = () => {
size="sm"
variant="tertiary"
>
{statuses.isStarting ? (
{statuses.isStarting || isPendingToStart ? (
<>
<Spinner size={20} />
{t('start.loading')}
@@ -206,6 +218,20 @@ export const ScreenRecordingSidePanel = () => {
)}
</>
)}
<Dialog
isOpen={!!isErrorDialogOpen}
role="alertdialog"
aria-label={t('alert.title')}
>
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
<Button
variant="text"
size="sm"
onPress={() => setIsErrorDialogOpen('')}
>
{t('alert.button')}
</Button>
</Dialog>
</Div>
)
}
@@ -1,4 +1,4 @@
import { A, Button, Div, H, LinkButton, Text } from '@/primitives'
import { A, Button, Dialog, Div, H, LinkButton, P, Text } from '@/primitives'
import thirdSlide from '@/assets/intro-slider/3_resume.png'
import { css } from '@/styled-system/css'
@@ -12,7 +12,7 @@ import {
useStopRecording,
} from '../index'
import { useEffect, useMemo, useState } from 'react'
import { RoomEvent } from 'livekit-client'
import { ConnectionState, RoomEvent } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { RecordingStatus, recordingStore } from '@/stores/recording'
import {
@@ -32,6 +32,8 @@ export const TranscriptSidePanel = () => {
const [isLoading, setIsLoading] = useState(false)
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
const recordingSnap = useSnapshot(recordingStore)
const { notifyParticipants } = useNotifyParticipants()
@@ -42,8 +44,15 @@ export const TranscriptSidePanel = () => {
)
const roomId = useRoomId()
const { mutateAsync: startRecordingRoom } = useStartRecording()
const { mutateAsync: stopRecordingRoom } = useStopRecording()
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
useStartRecording({
onError: () => setIsErrorDialogOpen('start'),
})
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
useStopRecording({
onError: () => setIsErrorDialogOpen('stop'),
})
const statuses = useMemo(() => {
return {
@@ -58,6 +67,7 @@ export const TranscriptSidePanel = () => {
const isRecordingTransitioning = useIsRecordingTransitioning()
const room = useRoomContext()
const isRoomConnected = room.state == ConnectionState.Connected
useEffect(() => {
const handleRecordingStatusChanged = () => {
@@ -98,8 +108,11 @@ export const TranscriptSidePanel = () => {
const isDisabled = useMemo(
() =>
isLoading || isRecordingTransitioning || statuses.isAnotherModeStarted,
[isLoading, isRecordingTransitioning, statuses]
isLoading ||
isRecordingTransitioning ||
statuses.isAnotherModeStarted ||
!isRoomConnected,
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
)
return (
@@ -177,7 +190,7 @@ export const TranscriptSidePanel = () => {
</>
) : (
<>
{statuses.isStopping ? (
{statuses.isStopping || isPendingToStop ? (
<>
<H lvl={3} margin={false}>
{t('stopping.heading')}
@@ -225,7 +238,7 @@ export const TranscriptSidePanel = () => {
size="sm"
variant="tertiary"
>
{statuses.isStarting ? (
{statuses.isStarting || isPendingToStart ? (
<>
<Spinner size={20} />
{t('start.loading')}
@@ -240,6 +253,20 @@ export const TranscriptSidePanel = () => {
)}
</>
)}
<Dialog
isOpen={!!isErrorDialogOpen}
role="alertdialog"
aria-label={t('alert.title')}
>
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
<Button
variant="text"
size="sm"
onPress={() => setIsErrorDialogOpen('')}
>
{t('alert.button')}
</Button>
</Dialog>
</Div>
)
}
@@ -13,9 +13,11 @@ import { ErrorScreen } from '@/components/ErrorScreen'
import { LoadingScreen } from '@/components/LoadingScreen'
import { fetchRecording } from '../api/fetchRecording'
import { RecordingStatus } from '@/features/recording'
import { useConfig } from '@/api/useConfig'
export const RecordingDownload = () => {
const { t } = useTranslation('recording')
const { data: configData } = useConfig()
const { recordingId } = useParams()
const { isLoggedIn, isLoading: isAuthLoading } = useUser()
@@ -50,6 +52,17 @@ export const RecordingDownload = () => {
return <ErrorScreen title={t('unsaved.title')} body={t('unsaved.body')} />
}
if (data.is_expired) {
return (
<ErrorScreen
title={t('expired.title')}
body={t('expired.body', {
date: formatDate(data?.expired_at, 'YYYY-MM-DD HH:mm'),
})}
/>
)
}
return (
<UserAware>
<Screen layout="centered" footer={false}>
@@ -74,6 +87,16 @@ export const RecordingDownload = () => {
}),
}}
/>
<span>
{configData?.recording?.expiration_days && (
<>
{' '}
{t('success.expiration', {
expiration_days: configData?.recording?.expiration_days,
})}
</>
)}
</span>
</Text>
<LinkButton
href={mediaUrl(data.key)}
@@ -353,7 +353,6 @@ export const Join = ({
type="text"
onChange={setUsername}
label={t('usernameLabel')}
aria-label={t('usernameLabel')}
defaultValue={initialUserChoices?.username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
@@ -6,6 +6,7 @@ import { styled, VStack } from '@/styled-system/jsx'
import { usePostHog } from 'posthog-js/react'
import { PostHog } from 'posthog-js'
import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
const Card = styled('div', {
base: {
@@ -299,6 +300,7 @@ const AuthenticationMessage = ({
}
export const Rating = () => {
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const posthog = usePostHog()
const isUserAnonymous = useMemo(() => {
@@ -307,6 +309,8 @@ export const Rating = () => {
const [step, setStep] = useState(0)
if (!isAnalyticsEnabled) return
if (step == 0) {
return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
}
+5 -1
View File
@@ -1,5 +1,9 @@
export { Room as RoomRoute } from './routes/Room'
export { FeedbackRoute } from './routes/Feedback'
export { roomIdPattern, isRoomValid } from './utils/isRoomValid'
export {
roomIdPattern,
isRoomValid,
flexibleRoomIdPattern,
} from './utils/isRoomValid'
export { generateRoomId } from './utils/generateRoomId'
export { useCreateRoom } from './api/createRoom'
@@ -0,0 +1,42 @@
import { Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useParticipantInfo } from '@livekit/components-react'
import { Participant } from 'livekit-client'
export const ParticipantName = ({
participant,
isScreenShare = false,
}: {
participant: Participant
isScreenShare: boolean
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTile' })
const { identity, name } = useParticipantInfo({ participant })
const displayedName = name != '' ? name : identity
if (isScreenShare) {
return (
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
marginLeft: '0.4rem',
}}
>
{t('screenShare', { name: displayedName })}
</Text>
)
}
return (
<Text
variant="sm"
style={{
paddingBottom: '0.1rem',
}}
>
{displayedName}
</Text>
)
}
@@ -2,7 +2,6 @@ import {
AudioTrack,
ConnectionQualityIndicator,
LockLockedIcon,
ParticipantName,
ParticipantTileProps,
ScreenShareIcon,
useEnsureTrackRef,
@@ -29,6 +28,7 @@ import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus'
import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -97,6 +97,8 @@ export const ParticipantTile: (
participant: trackReference.participant,
})
const isScreenShare = trackReference.source != Track.Source.Camera
return (
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
@@ -129,45 +131,50 @@ export const ParticipantTile: (
{!disableMetadata && (
<div className="lk-participant-metadata">
<HStack gap={0.25}>
<MutedMicIndicator
participant={trackReference.participant}
/>
{!isScreenShare && (
<MutedMicIndicator
participant={trackReference.participant}
/>
)}
<div
className="lk-participant-metadata-item"
style={{
minHeight: '24px',
backgroundColor: isHandRaised ? 'white' : undefined,
color: isHandRaised ? 'black' : undefined,
padding: '0.1rem 0.25rem',
backgroundColor:
isHandRaised && !isScreenShare ? 'white' : undefined,
color:
isHandRaised && !isScreenShare ? 'black' : undefined,
transition: 'background 200ms ease, color 400ms ease',
}}
>
{trackReference.source === Track.Source.Camera ? (
<>
{isHandRaised && (
<RiHand
color="black"
size={16}
style={{
marginInlineEnd: '.25rem', // fixme - match TrackMutedIndicator styling
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
)}
{isEncrypted && (
<LockLockedIcon
style={{ marginRight: '0.25rem' }}
/>
)}
<ParticipantName />
</>
) : (
<>
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
<ParticipantName>&apos;s screen</ParticipantName>
</>
{isHandRaised && !isScreenShare && (
<RiHand
color="black"
size={16}
style={{
marginRight: '0.4rem',
minWidth: '16px',
animationDuration: '300ms',
animationName: 'wave_hand',
animationIterationCount: '2',
}}
/>
)}
{isScreenShare && (
<ScreenShareIcon
style={{
maxWidth: '20px',
width: '100%',
}}
/>
)}
{isEncrypted && !isScreenShare && (
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
)}
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
@@ -15,6 +15,7 @@ export const INITIAL_POSITION = 200
interface FloatingReactionProps {
emoji: string
name?: string
isLocal?: boolean
speed?: number
scale?: number
}
@@ -22,6 +23,7 @@ interface FloatingReactionProps {
export function FloatingReaction({
emoji,
name,
isLocal = false,
speed = 1,
scale = 1,
}: FloatingReactionProps) {
@@ -60,7 +62,6 @@ export function FloatingReaction({
return (
<div
className={css({
fontSize: '3rem',
position: 'absolute',
display: 'flex',
alignItems: 'center',
@@ -69,23 +70,27 @@ export function FloatingReaction({
style={{
left: left,
bottom: INITIAL_POSITION + deltaY,
transform: `scale(${scale})`,
opacity: opacity,
}}
>
<span
<img
src={`/assets/reactions/${emoji}.png`}
alt={''}
className={css({
lineHeight: '57px',
height: '50px',
})}
>
{emoji}
</span>
style={{
transform: `scale(${scale})`,
transformOrigin: 'center bottom',
}}
/>
{name && (
<Text
variant="sm"
className={css({
backgroundColor: 'primaryDark.100',
color: 'white',
backgroundColor: isLocal ? 'primary.100' : 'primaryDark.100',
color: isLocal ? 'black' : 'white',
fontWeight: 500,
textAlign: 'center',
borderRadius: '20px',
paddingX: '0.5rem',
@@ -128,6 +133,7 @@ export function ReactionPortal({
speed={speed}
scale={scale}
name={participant?.isLocal ? t('you') : participant.name}
isLocal={participant?.isLocal}
/>
</div>,
document.body
@@ -110,9 +110,10 @@ const StyledSidePanel = ({
type PanelProps = {
isOpen: boolean
children: React.ReactNode
keepAlive?: boolean
}
const Panel = ({ isOpen, children }: PanelProps) => (
const Panel = ({ isOpen, keepAlive = false, children }: PanelProps) => (
<div
style={{
display: isOpen ? 'inherit' : 'none',
@@ -121,7 +122,7 @@ const Panel = ({ isOpen, children }: PanelProps) => (
flexGrow: 1,
}}
>
{children}
{keepAlive || isOpen ? children : null}
</div>
)
@@ -160,7 +161,7 @@ export const SidePanel = () => {
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen}>
<Panel isOpen={isChatOpen} keepAlive={true}>
<Chat />
</Panel>
<Panel isOpen={isToolsOpen}>
@@ -1,4 +1,4 @@
import { ProcessorOptions, Track } from 'livekit-client'
import { ProcessorOptions, Track, TrackProcessor } from 'livekit-client'
import posthog from 'posthog-js'
import {
FilesetResolver,
@@ -11,19 +11,20 @@ import {
TIMEOUT_TICK,
timerWorkerScript,
} from './TimerWorker'
import {
BackgroundProcessorInterface,
BackgroundOptions,
ProcessorType,
} from '.'
import { ProcessorType } from '.'
const PROCESSING_WIDTH = 256 * 3
const PROCESSING_HEIGHT = 144 * 3
const FACE_LANDMARKS_CANVAS_ID = 'face-landmarks-local'
export class FaceLandmarksProcessor implements BackgroundProcessorInterface {
options: BackgroundOptions
export type FaceLandmarksOptions = {
showGlasses: boolean
showFrench: boolean
}
export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
options: FaceLandmarksOptions
name: string
processedTrack?: MediaStreamTrack | undefined
@@ -50,7 +51,7 @@ export class FaceLandmarksProcessor implements BackgroundProcessorInterface {
glassesImage?: HTMLImageElement
mustacheImage?: HTMLImageElement
beretImage?: HTMLImageElement
constructor(opts: BackgroundOptions) {
constructor(opts: FaceLandmarksOptions) {
this.name = 'face_landmarks'
this.options = opts
this.type = ProcessorType.FACE_LANDMARKS
@@ -314,7 +315,7 @@ export class FaceLandmarksProcessor implements BackgroundProcessorInterface {
return element
}
update(opts: BackgroundOptions): void {
update(opts: FaceLandmarksOptions): void {
this.options = opts
}
@@ -3,13 +3,10 @@ import { Track, TrackProcessor } from 'livekit-client'
import { BackgroundBlurTrackProcessorJsWrapper } from './BackgroundBlurTrackProcessorJsWrapper'
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
import { BackgroundVirtualTrackProcessorJsWrapper } from './BackgroundVirtualTrackProcessorJsWrapper'
import { FaceLandmarksProcessor } from './FaceLandmarksProcessor'
export type BackgroundOptions = {
blurRadius?: number
imagePath?: string
showGlasses?: boolean
showFrench?: boolean
}
export interface ProcessorSerialized {
@@ -32,12 +29,12 @@ export enum ProcessorType {
}
export class BackgroundProcessorFactory {
static hasModernApiSupport() {
return ProcessorWrapper.hasModernApiSupport
}
static isSupported() {
return (
ProcessorWrapper.isSupported ||
BackgroundCustomProcessor.isSupported ||
FaceLandmarksProcessor.isSupported
)
return ProcessorWrapper.isSupported || BackgroundCustomProcessor.isSupported
}
static getProcessor(
@@ -58,10 +55,6 @@ export class BackgroundProcessorFactory {
if (BackgroundCustomProcessor.isSupported) {
return new BackgroundCustomProcessor(opts)
}
} else if (type === ProcessorType.FACE_LANDMARKS) {
if (FaceLandmarksProcessor.isSupported) {
return new FaceLandmarksProcessor(opts)
}
}
return undefined
}
@@ -2,14 +2,17 @@ import { RiMegaphoneLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
import { useConfig } from '@/api/useConfig'
export const FeedbackMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { data } = useConfig()
if (!data?.feedback?.url) return
return (
<MenuItem
href={GRIST_FEEDBACKS_FORM}
href={data?.feedback?.url}
target="_blank"
className={menuRecipe({ icon: true, variant: 'dark' }).item}
>
@@ -33,19 +33,20 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity
const label = isLocal(participant)
? t('participants.muteYourself')
: t('participants.muteParticipant', {
name,
})
return (
<>
<Button
square
variant="greyscale"
size="sm"
tooltip={
isLocal(participant)
? t('participants.muteYourself')
: t('participants.muteParticipant', {
name,
})
}
tooltip={label}
aria-label={label}
isDisabled={isMuted}
onPress={() =>
!isMuted && isLocal(participant)
@@ -55,7 +56,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
data-attr="participants-mute"
>
{isMuted ? (
<RiMicOffFill color={'gray'} />
<RiMicOffFill color={'gray'} aria-hidden={true} />
) : (
<RiMicFill
className={css({
@@ -64,6 +65,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
? 'pulse_background 800ms infinite'
: undefined,
})}
aria-hidden={true}
/>
)}
</Button>
@@ -12,9 +12,18 @@ import {
} from '@/features/rooms/livekit/components/ReactionPortal'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
import useRateLimiter from '@/hooks/useRateLimiter'
// eslint-disable-next-line react-refresh/only-export-components
export const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
export enum Emoji {
THUMBS_UP = 'thumbs-up',
THUMBS_DOWN = 'thumbs-down',
CLAP = 'clapping-hands',
HEART = 'red-heart',
LAUGHING = 'face-with-tears-of-joy',
SURPRISED = 'face-with-open-mouth',
CELEBRATION = 'party-popper',
}
export interface Reaction {
id: number
@@ -56,6 +65,12 @@ export const ReactionsToggle = () => {
}, ANIMATION_DURATION)
}
const debouncedSendReaction = useRateLimiter({
callback: sendReaction,
maxCalls: 10,
windowMs: 1000,
})
// Custom animation implementation for the emoji toolbar
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
// animation isn't perfect
@@ -105,10 +120,10 @@ export const ReactionsToggle = () => {
<div
className={css({
position: 'absolute',
top: -55,
left: -114,
top: -63,
left: -139,
borderRadius: '8px',
padding: '0.25rem',
padding: '0.35rem',
backgroundColor: 'primaryDark.50',
opacity: opacity,
transition: 'opacity 0.2s ease',
@@ -122,24 +137,29 @@ export const ReactionsToggle = () => {
<RACToolbar
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
{EMOJIS.map((emoji, index) => (
{Object.values(Emoji).map((emoji, index) => (
<Button
key={index}
onPress={() => sendReaction(emoji)}
onPress={() => debouncedSendReaction(emoji)}
aria-label={t('send', { emoji })}
variant="primaryTextDark"
size="sm"
square
data-attr={`send-reaction-${emoji}`}
>
<span
<img
src={`/assets/reactions/${emoji}.png`}
alt=""
className={css({
fontSize: '20px',
minHeight: '28px',
minWidth: '28px',
pointerEvents: 'none',
userSelect: 'none',
})}
>
{emoji}
</span>
/>
</Button>
))}
</RACToolbar>
@@ -15,12 +15,9 @@ import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import {
RiProhibited2Line,
RiGlassesLine,
RiGoblet2Fill,
} from '@remixicon/react'
import { useHasFaceLandmarksAccess } from '../../hooks/useHasFaceLandmarksAccess'
import { RiProhibited2Line } from '@remixicon/react'
import { FunnyEffects } from './FunnyEffects'
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
enum BlurRadius {
NONE = 0,
@@ -55,7 +52,7 @@ export const EffectsConfiguration = ({
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
const [processorPending, setProcessorPending] = useState(false)
const processorPendingReveal = useSyncAfterDelay(processorPending)
const hasFaceLandmarksAccess = useHasFaceLandmarksAccess()
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
useEffect(() => {
const videoElement = videoRef.current
@@ -114,6 +111,13 @@ export const EffectsConfiguration = ({
type,
options
)!
// IMPORTANT: Must explicitly stop previous processor before setting a new one
// in browsers without modern API support to prevent UI crashes.
// This workaround is needed until this issue is resolved:
// https://github.com/livekit/track-processors-js/issues/85
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
await videoTrack.stopProcessor()
}
await videoTrack.setProcessor(newProcessor)
onSubmit?.(newProcessor)
} else {
@@ -145,42 +149,9 @@ export const EffectsConfiguration = ({
}
const tooltipLabel = (type: ProcessorType, options: BackgroundOptions) => {
if (type === ProcessorType.FACE_LANDMARKS) {
const effect = options.showGlasses ? 'glasses' : 'french'
return t(
`faceLandmarks.${effect}.${isSelected(type, options) ? 'clear' : 'apply'}`
)
}
return t(`${type}.${isSelected(type, options) ? 'clear' : 'apply'}`)
}
const getFaceLandmarksOptions = () => {
const processor = getProcessor()
if (processor?.serialize().type === ProcessorType.FACE_LANDMARKS) {
return processor.serialize().options as {
showGlasses?: boolean
showFrench?: boolean
}
}
return { showGlasses: false, showFrench: false }
}
const toggleFaceLandmarkEffect = async (effect: 'glasses' | 'french') => {
const currentOptions = getFaceLandmarksOptions()
const newOptions = {
...currentOptions,
[effect === 'glasses' ? 'showGlasses' : 'showFrench']:
!currentOptions[effect === 'glasses' ? 'showGlasses' : 'showFrench'],
}
if (!newOptions.showGlasses && !newOptions.showFrench) {
// If both effects are off stop the processor
await clearEffect()
} else {
await toggleEffect(ProcessorType.FACE_LANDMARKS, newOptions)
}
}
return (
<div
className={css(
@@ -215,7 +186,7 @@ export const EffectsConfiguration = ({
muted
style={{
transform: 'rotateY(180deg)',
minHeight: '175px',
[layout === 'vertical' ? 'height' : 'minHeight']: '175px',
borderRadius: '8px',
}}
/>
@@ -268,6 +239,13 @@ export const EffectsConfiguration = ({
: {}
)}
>
{hasFunnyEffectsAccess && (
<FunnyEffects
videoTrack={videoTrack}
isPending={processorPendingReveal}
onPending={setProcessorPending}
/>
)}
{isSupported ? (
<>
<div>
@@ -340,8 +318,6 @@ export const EffectsConfiguration = ({
<BlurOnStrong />
</ToggleButton>
</div>
</div>
{hasFaceLandmarksAccess && (
<div
className={css({
marginTop: '1.5rem',
@@ -354,114 +330,51 @@ export const EffectsConfiguration = ({
}}
variant="bodyXsBold"
>
{t('faceLandmarks.title')}
{t('virtual.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
flexWrap: 'wrap',
})}
>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
showGlasses: true,
showFrench: false,
})}
tooltip={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
showGlasses: true,
showFrench: false,
})}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleFaceLandmarkEffect('glasses')
}
isSelected={getFaceLandmarksOptions().showGlasses}
data-attr="toggle-glasses"
>
<RiGlassesLine />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
showGlasses: false,
showFrench: true,
})}
tooltip={tooltipLabel(ProcessorType.FACE_LANDMARKS, {
showGlasses: false,
showFrench: true,
})}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleFaceLandmarkEffect('french')
}
isSelected={getFaceLandmarksOptions().showFrench}
data-attr="toggle-french"
>
<RiGoblet2Fill />
</ToggleButton>
</div>
</div>
)}
<div
className={css({
marginTop: '1.5rem',
})}
>
<H
lvl={3}
style={{
marginBottom: '1rem',
}}
variant="bodyXsBold"
>
{t('virtual.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
flexWrap: 'wrap',
})}
>
{[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
return (
<ToggleButton
key={i}
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
{[...Array(8).keys()].map((i) => {
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
return (
<ToggleButton
key={i}
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
)
})}
})}
tooltip={tooltipLabel(ProcessorType.VIRTUAL, {
imagePath,
})}
isDisabled={processorPendingReveal}
onChange={async () =>
await toggleEffect(ProcessorType.VIRTUAL, {
imagePath,
})
}
isSelected={isSelected(ProcessorType.VIRTUAL, {
imagePath,
})}
className={css({
bgSize: 'cover',
})}
style={{
backgroundImage: `url(${thumbnailPath})`,
}}
data-attr={`toggle-virtual-${i}`}
/>
)
})}
</div>
</div>
</div>
</div>
<Information className={css({ marginTop: '1rem' })}>
<Text variant="sm"> {t('experimental')}</Text>
</Information>
</>
) : (
<Information>
@@ -0,0 +1,120 @@
import { css } from '@/styled-system/css'
import { H, ToggleButton } from '@/primitives'
import { ProcessorType } from '../blur'
import { RiGlassesLine, RiGoblet2Fill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { FaceLandmarksProcessor } from '../blur/FaceLandmarksProcessor'
import { LocalVideoTrack } from 'livekit-client'
export type FunnyEffectsProps = {
videoTrack: LocalVideoTrack
isPending?: boolean
onPending: (value: boolean) => void
}
export const FunnyEffects = ({
videoTrack,
isPending,
onPending,
}: FunnyEffectsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
const getOptions = () => {
const processor = videoTrack?.getProcessor() as FaceLandmarksProcessor
if (!processor || processor.type != ProcessorType.FACE_LANDMARKS) {
return {
showGlasses: false,
showFrench: false,
}
}
return processor.serialize().options
}
const options = getOptions()
const toggleFaceLandmarkEffect = async (
showEffect: 'showGlasses' | 'showFrench'
) => {
const options = getOptions()
const processor = videoTrack?.getProcessor() as FaceLandmarksProcessor
const newOptions = {
...options,
[showEffect]: !options[showEffect],
}
onPending(true)
try {
if (!newOptions.showGlasses && !newOptions.showFrench) {
await videoTrack.stopProcessor()
} else if (options.showGlasses || options.showFrench) {
await processor?.update(newOptions)
} else {
const newProcessor = new FaceLandmarksProcessor(newOptions)
await videoTrack.setProcessor(newProcessor)
}
} catch (e) {
console.error('could not update processor', e)
} finally {
onPending(false)
}
}
const getLabelAction = (enabled: boolean) => (enabled ? 'clear' : 'apply')
return (
<div
className={css({
marginBottom: '1.5rem',
})}
>
<H
lvl={3}
style={{
marginBottom: '1rem',
}}
variant="bodyXsBold"
>
{t('faceLandmarks.title')}
</H>
<div
className={css({
display: 'flex',
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
aria-label={t(
`faceLandmarks.glasses.${getLabelAction(options.showGlasses)}`
)}
tooltip={t(
`faceLandmarks.glasses.${getLabelAction(options.showGlasses)}`
)}
isDisabled={isPending}
onChange={async () => await toggleFaceLandmarkEffect('showGlasses')}
isSelected={options.showGlasses}
data-attr="toggle-glasses"
>
<RiGlassesLine />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={t(
`faceLandmarks.french.${getLabelAction(options.showFrench)}`
)}
tooltip={t(
`faceLandmarks.french.${getLabelAction(options.showFrench)}`
)}
isDisabled={isPending}
onChange={async () => await toggleFaceLandmarkEffect('showFrench')}
isSelected={options.showFrench}
data-attr="toggle-french"
>
<RiGoblet2Fill />
</ToggleButton>
</div>
</div>
)
}
@@ -1,10 +0,0 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { FeatureFlags } from '@/features/analytics/enums'
export const useHasFaceLandmarksAccess = () => {
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
return featureEnabled || !isAnalyticsEnabled
}
@@ -0,0 +1,22 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { FeatureFlags } from '@/features/analytics/enums'
import useKonami from '@/features/rooms/livekit/hooks/useKonami'
import { konamiStore } from '@/stores/konami'
import { useSnapshot } from 'valtio'
export const useHasFunnyEffectsAccess = () => {
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
const isAnalyticsEnabled = useIsAnalyticsEnabled()
const konamiSnap = useSnapshot(konamiStore)
useKonami(
() =>
(konamiStore.areFunnyEffectsEnabled = !konamiSnap.areFunnyEffectsEnabled)
)
return (
(featureEnabled || !isAnalyticsEnabled) && konamiSnap.areFunnyEffectsEnabled
)
}
@@ -0,0 +1,35 @@
/**
* Konami Code Detector Component
* This implementation is taken from: vmarchesin/react-konami-code
*/
import { useCallback, useEffect, useState } from 'react'
export const KONAMI_CODE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65]
function useKonami(action: () => void, { code = KONAMI_CODE } = {}) {
const [input, setInput] = useState<number[]>([])
const onKeyUp = useCallback(
(e: KeyboardEvent) => {
const newInput = input
newInput.push(e.keyCode)
newInput.splice(-code.length - 1, input.length - code.length)
setInput(newInput)
if (newInput.join('').includes(code.join(''))) {
action()
}
},
[input, setInput, code, action]
)
useEffect(() => {
document.addEventListener('keyup', onKeyUp)
return () => {
document.removeEventListener('keyup', onKeyUp)
}
}, [onKeyUp])
}
export default useKonami
@@ -19,23 +19,38 @@ export function usePersistentUserChoices(
)
const saveAudioInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev) => ({ ...prev, audioEnabled: isEnabled }))
setSettings((prev: LocalUserChoices) => ({
...prev,
audioEnabled: isEnabled,
}))
}, [])
const saveVideoInputEnabled = React.useCallback((isEnabled: boolean) => {
setSettings((prev) => ({ ...prev, videoEnabled: isEnabled }))
setSettings((prev: LocalUserChoices) => ({
...prev,
videoEnabled: isEnabled,
}))
}, [])
const saveAudioInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev) => ({ ...prev, audioDeviceId: deviceId }))
setSettings((prev: LocalUserChoices) => ({
...prev,
audioDeviceId: deviceId,
}))
}, [])
const saveVideoInputDeviceId = React.useCallback((deviceId: string) => {
setSettings((prev) => ({ ...prev, videoDeviceId: deviceId }))
setSettings((prev: LocalUserChoices) => ({
...prev,
videoDeviceId: deviceId,
}))
}, [])
const saveUsername = React.useCallback((username: string) => {
setSettings((prev) => ({ ...prev, username: username }))
setSettings((prev: LocalUserChoices) => ({ ...prev, username: username }))
}, [])
const saveProcessorSerialized = React.useCallback(
(processorSerialized?: ProcessorSerialized) => {
setSettings((prev) => ({ ...prev, processorSerialized }))
setSettings((prev: LocalUserChoices) => ({
...prev,
processorSerialized,
}))
},
[]
)
@@ -14,7 +14,6 @@ import {
RiMore2Line,
RiSettings3Line,
} from '@remixicon/react'
import { GRIST_FEEDBACKS_FORM } from '@/utils/constants'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { ChatToggle } from '../../components/controls/ChatToggle'
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
@@ -24,6 +23,7 @@ import { useSettingsDialog } from '../../components/controls/SettingsDialogConte
import { ResponsiveMenu } from './ResponsiveMenu'
import { ToolsToggle } from '../../components/controls/ToolsToggle'
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
import { useConfig } from '@/api/useConfig'
export function MobileControlBar({
onDeviceError,
@@ -38,6 +38,8 @@ export function MobileControlBar({
const { toggleEffects } = useSidePanel()
const { setDialogOpen } = useSettingsDialog()
const { data } = useConfig()
return (
<>
<div
@@ -150,17 +152,19 @@ export function MobileControlBar({
>
<RiAccountBoxLine size={20} />
</Button>
<LinkButton
href={GRIST_FEEDBACKS_FORM}
variant="primaryTextDark"
tooltip={t('options.items.feedback')}
aria-label={t('options.items.feedback')}
description={true}
target="_blank"
onPress={() => setIsMenuOpened(false)}
>
<RiMegaphoneLine size={20} />
</LinkButton>
{data?.feedback?.url && (
<LinkButton
href={data?.feedback?.url}
variant="primaryTextDark"
tooltip={t('options.items.feedback')}
aria-label={t('options.items.feedback')}
description={true}
target="_blank"
onPress={() => setIsMenuOpened(false)}
>
<RiMegaphoneLine size={20} />
</LinkButton>
)}
<Button
onPress={() => {
setDialogOpen(true)
@@ -3,13 +3,17 @@ import {
usePersistentUserChoices,
type LocalUserChoices as LocalUserChoicesLK,
} from '@livekit/components-react'
import { useParams } from 'wouter'
import { useLocation, useParams } from 'wouter'
import { ErrorScreen } from '@/components/ErrorScreen'
import { useUser, UserAware } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
import { ProcessorSerialized } from '../livekit/components/blur'
import {
isRoomValid,
normalizeRoomId,
} from '@/features/rooms/utils/isRoomValid'
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
@@ -21,6 +25,7 @@ export const Room = () => {
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const { roomId } = useParams()
const [location, setLocation] = useLocation()
const initialRoomData = history.state?.initialRoomData
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
@@ -40,6 +45,12 @@ export const Room = () => {
}
}, [])
useEffect(() => {
if (roomId && !isRoomValid(roomId)) {
setLocation(normalizeRoomId(roomId))
}
}, [roomId, setLocation, location])
if (!roomId) {
return <ErrorScreen />
}
@@ -1,5 +1,17 @@
export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
// Case-insensitive and with optional hyphens
export const flexibleRoomIdPattern =
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
export const isRoomValid = (roomIdOrUrl: string) =>
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
export const normalizeRoomId = (roomId: string) => {
const cleanId = roomId.toLowerCase().replace(/-/g, '')
if (cleanId.length === 10) {
return `${cleanId.slice(0, 3)}-${cleanId.slice(3, 7)}-${cleanId.slice(7, 10)}`
}
return roomId
}
@@ -0,0 +1,8 @@
import { useLocation } from 'wouter'
const SDK_BASE_ROUTE = '/sdk'
export const useIsSdkContext = () => {
const [location] = useLocation()
return location.includes(SDK_BASE_ROUTE)
}
+37
View File
@@ -0,0 +1,37 @@
import { useCallback, useRef } from 'react'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type RateLimiterProps<T extends (...args: any[]) => any> = {
callback: T
maxCalls: number
windowMs: number
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function useRateLimiter<T extends (...args: any[]) => any>({
callback,
maxCalls = 5,
windowMs = 1000,
}: RateLimiterProps<T>) {
const callsCountRef = useRef(0)
const resetTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const rateLimitedFn = useCallback(
(...args: Parameters<T>) => {
if (callsCountRef.current < maxCalls) {
callsCountRef.current += 1
if (callsCountRef.current === 1) {
resetTimeoutRef.current = setTimeout(() => {
callsCountRef.current = 0
resetTimeoutRef.current = undefined
}, windowMs)
}
return callback(...args)
} else {
return null
}
},
[callback, maxCalls, windowMs]
)
return rateLimitedFn
}
+1 -1
View File
@@ -14,7 +14,7 @@ i18n
.use(initReactI18next)
.use(LanguageDetector)
.init({
supportedLngs: ['en', 'fr', 'nl'],
supportedLngs: ['en', 'fr', 'nl', 'de'],
fallbackLng: 'fr',
ns: i18nDefaultNamespace,
detection: {
+18 -18
View File
@@ -1,33 +1,33 @@
{
"accessibility": {
"title": "",
"introduction": "",
"title": "Barrierefreiheit",
"introduction": "<i>Visio</i> verpflichtet sich, seine digitalen Dienste barrierefrei zu gestalten, gemäß Artikel 47 des Gesetzes Nr. 2005-102 vom 11. Februar 2005.",
"declaration": {
"title": "",
"date": ""
"title": "Erklärung zur Barrierefreiheit",
"date": "Erstellt am 4. Dezember 2024."
},
"scope": "",
"scope": "Diese Erklärung zur Barrierefreiheit gilt für die Website visio.numerique.gouv.fr",
"complianceStatus": {
"title": "",
"body": ""
"title": "Stand der Vereinbarkeit",
"body": "visio.numerique.gouv.fr ist nicht konform mit RGAA 4.1. Die Website wurde noch nicht geprüft. Das Team bemüht sich jedoch, eine für alle zugängliche Seite gemäß den Empfehlungen des RGAA zu erstellen."
},
"improvement": {
"title": "",
"body": "",
"title": "Verbesserung und Kontakt",
"body": "Wenn Sie auf Inhalte oder Dienste nicht zugreifen können, können Sie die verantwortliche Person von lasuite.numerique.gouv.fr kontaktieren, um eine barrierefreie Alternative zu erhalten oder die Inhalte in einer anderen Form zu bekommen.",
"contact": {
"email": "",
"address": ""
"email": "E-Mail: visio@numerique.gouv.fr",
"address": "Adresse: DINUM, 20 avenue de Ségur, 75007 Paris"
},
"response": ""
"response": "Wir bemühen uns, innerhalb von 2 Werktagen zu antworten."
},
"recourse": {
"title": "",
"introduction": "",
"title": "Rechtsbehelfe",
"introduction": "Dieses Verfahren ist in folgendem Fall zu verwenden: Sie haben der für die Website zuständigen Stelle ein Problem mit der Barrierefreiheit gemeldet, das Sie daran hindert, auf Inhalte oder Dienste des Portals zuzugreifen, und keine zufriedenstellende Antwort erhalten.",
"options": {
"intro": "",
"option1": "",
"option2": "",
"option3": ""
"intro": "Sie können:",
"option1": "Dem*der Beauftragten für Rechte eine Nachricht schreiben",
"option2": "Den*die Vertreter*in des*der Beauftragten für Rechte in Ihrer Region kontaktieren",
"option3": "Einen Brief per Post senden (kostenfrei, ohne Briefmarke): </br> Défenseur des droits, Libre réponse 71120, 75342 Paris CEDEX 07"
}
}
}
+33 -33
View File
@@ -1,55 +1,55 @@
{
"app": "Visio",
"backToHome": "",
"cancel": "",
"closeDialog": "",
"backToHome": "Zurück zur Startseite",
"cancel": "Abbrechen",
"closeDialog": "Dialogfenster schließen",
"error": {
"heading": ""
"heading": "Beim Laden der Seite ist ein Fehler aufgetreten"
},
"feedback": {
"context": "",
"cta": ""
"context": "Produkt in Entwicklung — Ihr Feedback ist wichtig!",
"cta": "Teilen Sie uns Ihre Meinung mit"
},
"forbidden": {
"heading": ""
"heading": "Zugriff verweigert"
},
"loading": "",
"loggedInUserTooltip": "",
"loading": "Ladevorgang…",
"loggedInUserTooltip": "Angemeldet als…",
"login": {
"buttonLabel": "",
"linkLabel": "",
"link": ""
"buttonLabel": "Mit ProConnect anmelden",
"linkLabel": "Was ist ProConnect? neues Fenster",
"link": "Was ist ProConnect?"
},
"logout": "",
"logout": "Abmelden",
"notFound": {
"heading": "",
"body": ""
"heading": "Überprüfen Sie Ihren Meeting-Code",
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
},
"submit": "OK",
"footer": {
"links": {
"legifrance": "",
"infogouv": "",
"servicepublic": "",
"datagouv": "",
"legalsTerms": "",
"data": "",
"accessibility": "",
"ariaLabel": "",
"codeAnnotation": "",
"code": "",
"technicalDetails": "",
"termsOfService": ""
"legifrance": "legifrance.gouv.fr",
"infogouv": "info.gouv.fr",
"servicepublic": "service-public.fr",
"datagouv": "data.gouv.fr",
"legalsTerms": "Rechtliche Hinweise",
"data": "Datenschutz und Cookies",
"accessibility": "Barrierefreiheit: nicht konform",
"ariaLabel": "neues Fenster",
"codeAnnotation": "Unser Code ist offen und verfügbar in diesem",
"code": "Open-Source-Code-Repository",
"technicalDetails": "Technisches Datenblatt",
"termsOfService": "Nutzungsbedingungen"
},
"mentions": "",
"license": ""
"mentions": "Sofern nicht anders angegeben, sind die Inhalte dieser Website verfügbar unter der",
"license": "Etalab 2.0 Lizenz"
},
"loginHint": {
"title": "",
"body": "",
"title": "Melden Sie sich mit Ihrem ProConnect-Konto an",
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
"button": {
"ariaLabel": "",
"label": ""
"ariaLabel": "Hinweis schließen",
"label": "OK"
}
}
}
+38 -38
View File
@@ -1,56 +1,56 @@
{
"createMeeting": "",
"heading": "",
"intro": "",
"joinInputError": "",
"joinInputExample": "",
"joinInputLabel": "",
"joinInputSubmit": "",
"joinMeeting": "",
"joinMeetingTipContent": "",
"joinMeetingTipHeading": "",
"loginToCreateMeeting": "",
"moreLinkLabel": "",
"moreLink": "",
"moreAbout": "",
"createMeeting": "Meeting erstellen",
"heading": "Einfache und sichere Videokonferenzen",
"intro": "Kommunizieren und arbeiten Sie mühelos, ohne Ihre digitale Souveränität zu gefährden",
"joinInputError": "Verwenden Sie einen Meeting-Link oder -Code. Beispiele:",
"joinInputExample": "URL oder 10-stelliger Code",
"joinInputLabel": "Meeting-Link",
"joinInputSubmit": "Meeting beitreten",
"joinMeeting": "Meeting beitreten",
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
"joinMeetingTipHeading": "Wussten Sie schon?",
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
"moreLinkLabel": "Mehr erfahren neues Tab",
"moreLink": "Mehr erfahren",
"moreAbout": "über Visio",
"createMenu": {
"laterOption": "",
"instantOption": ""
"laterOption": "Meeting für später planen",
"instantOption": "Sofort-Meeting starten"
},
"laterMeetingDialog": {
"heading": "",
"description": "",
"copy": "",
"copied": "",
"permissions": ""
"heading": "Ihre Zugangsdaten",
"description": "Senden Sie diesen Link an die Personen, die Sie zum Meeting einladen möchten. Sie können ohne ProConnect teilnehmen.",
"copy": "Meeting-Link kopieren",
"copied": "Link in die Zwischenablage kopiert",
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten."
},
"introSlider": {
"previous": {
"label": "",
"tooltip": ""
},
"beta": {
"text": "",
"tooltip": ""
"label": "Zurück",
"tooltip": "Zurück"
},
"next": {
"label": "",
"tooltip": ""
"label": "Weiter",
"tooltip": "Weiter"
},
"beta": {
"text": "An der Beta teilnehmen",
"tooltip": "Formular ausfüllen"
},
"slide1": {
"title": "",
"body": "",
"imgAlt": ""
"title": "Testen Sie Visio für Ihre täglichen Aufgaben",
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere.",
"imgAlt": "Illustration einer benutzerfreundlichen und barrierefreien Kollaborationsplattform"
},
"slide2": {
"title": "",
"body": "",
"imgAlt": ""
"title": "Gruppenanrufe ohne Einschränkungen",
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße.",
"imgAlt": "Bild eines virtuellen Meetings mit mehreren nahtlos zusammenarbeitenden Teilnehmenden"
},
"slide3": {
"title": "",
"body": "",
"imgAlt": ""
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!",
"imgAlt": "Illustration KI-gestützter Notizen in einem virtuellen Meeting"
}
}
}
+23 -23
View File
@@ -1,37 +1,37 @@
{
"title": "",
"title": "Rechtliche Hinweise",
"creator": {
"title": "",
"body": "",
"title": "Herausgeber",
"body": "Der Visio-Dienst wird herausgegeben von der Interministeriellen Digitaldirektion des Staates (DINUM) mit Sitz:",
"contact": {
"title": "",
"address": "",
"city": "",
"phone": "",
"siret": "",
"siren": ""
"title": "Kontaktdaten",
"address": "20 avenue de Ségur",
"city": "75007 Paris",
"phone": "Tel. Zentrale: 01.71.21.01.70",
"siret": "SIRET: 12000101100010 (Generalsekretariat der Regierung)",
"siren": "SIREN: 120 001 011"
}
},
"director": {
"title": "",
"body": ""
"title": "Verantwortliche für die Veröffentlichung",
"body": "Die Verantwortliche für die Veröffentlichung ist Frau Stéphanie Schaer, Interministerielle Direktorin für Digitalisierung."
},
"hosting": {
"title": "",
"body": ""
"title": "Hosting",
"body": "Der Dienst wird gehostet von Outscale West-1 CloudGouv SecNumCloud mit Standort in Frankreich."
},
"accessibility": {
"title": "",
"body": "",
"more": "",
"link": "",
"status": ""
"title": "Barrierefreiheit",
"body": "Die Einhaltung der Standards für digitale Barrierefreiheit ist ein zukünftiges Ziel. Die Website wurde noch nicht auditiert. Das Team bemüht sich jedoch, eine für alle zugängliche Website zu erstellen, unter Berücksichtigung der Empfehlungen des RGAA.",
"more": "Mehr erfahren: ",
"link": "Link zur Seite über Barrierefreiheit",
"status": "Barrierefreiheitsstatus: nicht konform"
},
"reuse": {
"title": "",
"body1": "",
"license": "",
"body2": "",
"body3": ""
"title": "Wiederverwendung von Inhalten und Links",
"body1": "Sofern nicht ausdrücklich anders angegeben und geistige Eigentumsrechte Dritter betroffen sind, werden die Inhalte dieser Website unter der ",
"license": "offenen Lizenz Etalab 2.0",
"body2": "bereitgestellt. Insbesondere dürfen Sie diese Inhalte frei vervielfältigen, kopieren, ändern, extrahieren, umwandeln, verbreiten, weiterleiten, veröffentlichen, übertragen und nutzen unter der Bedingung, dass die Quelle und das Datum der letzten Aktualisierung genannt werden und Dritte nicht durch die Informationen in die Irre geführt werden.",
"body3": "Jede öffentliche oder private Website ist berechtigt, ohne vorherige Genehmigung, einen Link (einschließlich eines Deep Links) zu den auf dieser Website veröffentlichten Informationen zu setzen."
}
}
+17 -17
View File
@@ -1,33 +1,33 @@
{
"defaultName": "",
"defaultName": "Ein Mitwirkender",
"joined": {
"description": ""
"description": "{{name}} ist dem Raum beigetreten"
},
"raised": {
"description": "",
"cta": ""
"description": "{{name}} hat die Hand gehoben.",
"cta": "Warteliste öffnen"
},
"muted": "",
"openChat": "",
"muted": "{{name}} hat dein Mikrofon stummgeschaltet. Kein Teilnehmer kann dich hören.",
"openChat": "Chat öffnen",
"lowerHand": {
"auto": "",
"dismiss": ""
"auto": "Es scheint, dass du angefangen hast zu sprechen, daher wird deine Hand gesenkt.",
"dismiss": "Hand oben lassen"
},
"reaction": {
"description": ""
"description": "{{name}} hat mit {{emoji}} reagiert"
},
"waitingParticipants": {
"one": "",
"several": "",
"open": "",
"accept": ""
"one": "Eine Person möchte diesem Anruf beitreten.",
"several": "Mehrere Personen möchten diesem Anruf beitreten.",
"open": "Öffnen",
"accept": "Annehmen"
},
"transcript": {
"started": "",
"stopped": ""
"started": "{{name}} hat die Transkription des Treffens gestartet.",
"stopped": "{{name}} hat die Transkription des Treffens gestoppt."
},
"screenRecording": {
"started": "",
"stopped": ""
"started": "{{name}} hat die Aufzeichnung des Treffens gestartet.",
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt."
}
}
+14 -9
View File
@@ -1,19 +1,24 @@
{
"error": {
"title": "",
"body": ""
"title": "Aufzeichnung nicht verfügbar",
"body": "Diese Aufzeichnung konnte nicht gefunden werden oder wurde gelöscht."
},
"expired": {
"title": "Aufzeichnung abgelaufen",
"body": "Diese Aufzeichnung wurde am {{date}} gelöscht."
},
"authentication": {
"title": "",
"body": ""
"title": "Authentifizierung erforderlich",
"body": "Bitte melden Sie sich an, um auf diese Aufzeichnung zuzugreifen."
},
"unsaved": {
"title": "",
"body": ""
"title": "Download nicht verfügbar",
"body": "Diese Aufzeichnung ist noch nicht zum Herunterladen bereit. Bitte versuchen Sie es später erneut."
},
"success": {
"title": "",
"body": "",
"button": ""
"title": "Ihre Aufzeichnung ist bereit!",
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
"button": "Herunterladen"
}
}
+217 -198
View File
@@ -1,146 +1,145 @@
{
"feedback": {
"heading": "",
"home": "",
"back": ""
"heading": "Sie haben das Meeting verlassen",
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
},
"join": {
"videoinput": {
"choose": "",
"disable": "",
"enable": "",
"label": "",
"placeholder": ""
"choose": "Kamera auswählen",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"label": "Kamera",
"placeholder": "Kamera aktivieren, um die Vorschau zu sehen"
},
"audioinput": {
"choose": "",
"disable": "",
"enable": "",
"label": ""
"choose": "Mikrofon auswählen",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"label": "Mikrofon"
},
"heading": "",
"effects": {
"description": "",
"title": "",
"subTitle": ""
"description": "Effekte anwenden",
"title": "Effekte",
"subTitle": "Konfigurieren Sie die Effekte Ihrer Kamera."
},
"joinLabel": "",
"joinMeeting": "",
"toggleOff": "",
"toggleOn": "",
"usernameHint": "",
"usernameLabel": "",
"heading": "Dem Meeting beitreten?",
"joinLabel": "Beitreten",
"joinMeeting": "Meeting beitreten",
"toggleOff": "Klicken, um auszuschalten",
"toggleOn": "Klicken, um einzuschalten",
"usernameHint": "Wird anderen Teilnehmern angezeigt",
"usernameLabel": "Ihr Name",
"errors": {
"usernameEmpty": ""
"usernameEmpty": "Ihr Name darf nicht leer sein"
},
"cameraDisabled": "",
"cameraStarting": "",
"cameraDisabled": "Kamera ist deaktiviert.",
"cameraStarting": "Kamera wird gestartet.",
"waiting": {
"title": "",
"body": ""
"title": "Beitrittsanfrage wird gesendet...",
"body": "Sie können diesem Anruf beitreten, sobald jemand Sie autorisiert"
},
"denied": {
"title": "",
"body": ""
"title": "Sie können diesem Anruf nicht beitreten",
"body": "Ihre Beitrittsanfrage wurde abgelehnt."
},
"timeoutInvite": {
"title": "",
"body": ""
"title": "Sie können diesem Anruf nicht beitreten",
"body": "Niemand hat auf Ihre Anfrage reagiert"
}
},
"leaveRoomPrompt": "",
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
"shareDialog": {
"copy": "",
"copied": "",
"heading": "",
"description": "",
"permissions": ""
"copy": "Meeting-Link kopieren",
"copyButton": "Link kopieren",
"copied": "Link in die Zwischenablage kopiert",
"heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten."
},
"error": {
"createRoom": {
"heading": "",
"body": ""
"heading": "Authentifizierung erforderlich",
"body": "Dieser Raum wurde noch nicht erstellt. Bitte authentifizieren Sie sich, um ihn zu erstellen, oder warten Sie, bis ein authentifizierter Benutzer dies tut."
},
"screenShare": {
"title": "",
"ariaLabel": "",
"message": "",
"macInstructions": "",
"macSystemPreferences": "",
"helpLinkText": "",
"helpLinkLabel": "",
"closeButton": "",
"newTab": ""
"title": "Bildschirmfreigabe nicht möglich",
"ariaLabel": "Bildschirmfreigabe nicht möglich",
"message": "Ihr Browser darf möglicherweise den Bildschirm Ihres Computers nicht aufzeichnen.",
"macInstructions": "Gehen Sie zu Ihren",
"macSystemPreferences": "Systemeinstellungen",
"helpLinkText": "Weitere Informationen finden Sie unter",
"helpLinkLabel": "Präsentationsproblem",
"closeButton": "Schließen",
"newTab": "Neues Fenster"
}
},
"controls": {
"microphone": "",
"camera": "",
"shareScreen": "",
"stopScreenShare": "",
"microphone": "Mikrofon",
"camera": "Kamera",
"chat": {
"open": "",
"closed": "",
"open": "Chat schließen",
"closed": "Chat öffnen",
"input": {
"textArea": {
"label": "",
"placeholder": ""
"label": "Nachricht eingeben",
"placeholder": "Nachricht eingeben"
},
"button": {
"label": ""
"label": "Nachricht senden"
}
}
},
"info": {
"open": "",
"closed": ""
"open": "Informationen ausblenden",
"closed": "Informationen anzeigen"
},
"hand": {
"raise": "",
"lower": ""
"raise": "Hand heben",
"lower": "Hand senken"
},
"screenShare": {
"start": "",
"stop": ""
"start": "Bildschirm freigeben",
"stop": "Bildschirmfreigabe beenden"
},
"leave": "",
"leave": "Verlassen",
"participants": {
"open": "",
"closed": ""
"open": "Alle ausblenden",
"closed": "Alle anzeigen"
},
"tools": {
"open": "",
"closed": ""
"open": "Weitere Tools ausblenden",
"closed": "Weitere Tools anzeigen"
},
"admin": {
"open": "",
"closed": ""
"open": "Admin ausblenden",
"closed": "Admin öffnen"
},
"moreOptions": "",
"moreOptions": "Weitere Optionen",
"reactions": {
"button": "",
"send": "",
"you": ""
"button": "Reaktion senden",
"send": "Reaktion {{emoji}} senden",
"you": "Sie"
}
},
"options": {
"buttonLabel": "",
"buttonLabel": "Weitere Optionen",
"items": {
"feedback": "",
"settings": "",
"username": "",
"effects": "",
"switchCamera": "",
"feedback": "Feedback geben",
"settings": "Einstellungen",
"username": "Ihren Namen aktualisieren",
"effects": "Effekte anwenden",
"switchCamera": "Kamera wechseln",
"fullscreen": {
"enter": "",
"exit": ""
"enter": "Vollbild",
"exit": "Vollbildmodus verlassen"
},
"support": ""
"support": "Support kontaktieren"
}
},
"effects": {
"activateCamera": "Ihre Kamera ist deaktiviert. Wählen Sie eine Option, um sie zu aktivieren.",
"notAvailable": "Videoeffekte werden in Kürze in Ihrem Browser verfügbar sein. Wir arbeiten daran! In der Zwischenzeit können Sie Google Chrome für beste Leistung oder Firefox verwenden :(",
"notAvailable": "Videoeffekte werden bald in Ihrem Browser verfügbar sein. Wir arbeiten daran! In der Zwischenzeit können Sie Google Chrome für die beste Leistung oder Firefox verwenden :(",
"heading": "Unschärfe",
"blur": {
"title": "Hintergrundunschärfe",
@@ -161,207 +160,227 @@
"clear": "Brille entfernen"
},
"french": {
"apply": "Französische Touch hinzufügen",
"clear": "Französische Touch entfernen"
"apply": "Französischen Touch hinzufügen",
"clear": "Französischen Touch entfernen"
}
},
"experimental": "Experimentelle Funktion. Eine v2 kommt für vollständige Browserunterstützung und verbesserte Qualität."
}
},
"sidePanel": {
"heading": {
"participants": "",
"effects": "",
"chat": "",
"transcript": "",
"screenRecording": "",
"admin": "",
"tools": "",
"info": ""
"participants": "Teilnehmer",
"effects": "Effekte",
"chat": "Nachrichten im Chat",
"transcript": "Transkription",
"screenRecording": "Aufzeichnung",
"admin": "Admin-Einstellungen",
"tools": "Weitere Tools",
"info": "Meeting-Informationen"
},
"content": {
"participants": "",
"effects": "",
"chat": "",
"transcript": "",
"screenRecording": "",
"admin": "",
"tools": "",
"info": ""
"participants": "Teilnehmer",
"effects": "Effekte",
"chat": "Nachrichten",
"transcript": "Transkription",
"screenRecording": "Aufzeichnung",
"admin": "Admin-Einstellungen",
"tools": "Weitere Tools",
"info": "Meeting-Informationen"
},
"closeButton": ""
"closeButton": "{{content}} ausblenden"
},
"chat": {
"disclaimer": ""
"disclaimer": "Die Nachrichten sind nur für Teilnehmer zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Anrufs gelöscht."
},
"moreTools": {
"body": "",
"moreLink": "",
"body": "Greifen Sie auf weitere Tools in Visio zu, um Ihre Meetings zu verbessern,",
"moreLink": "mehr erfahren",
"tools": {
"transcript": {
"title": "",
"body": ""
"title": "Transkription",
"body": "Führen Sie ein schriftliches Protokoll Ihres Meetings."
},
"screenRecording": {
"title": "",
"body": ""
"title": "Aufzeichnung",
"body": "Zeichnen Sie Ihr Meeting auf, um es später erneut anzusehen."
}
}
},
"info": {
"roomInformation": {
"title": "",
"title": "Verbindungsinformationen",
"button": {
"ariaLabel": "",
"copy": "",
"copied": ""
"ariaLabel": "Kopiere deine Meeting-Adresse",
"copy": "Adresse kopieren",
"copied": "Adresse kopiert"
}
}
},
"transcript": {
"start": {
"heading": "",
"body": "",
"button": "",
"linkMore": ""
"heading": "Dieses Gespräch transkribieren",
"body": "Dieses Gespräch automatisch transkribieren und die Zusammenfassung in Docs erhalten.",
"button": "Transkription starten",
"loading": "Transkription wird gestartet",
"linkMore": "Mehr erfahren"
},
"stop": {
"heading": "",
"body": "",
"button": ""
"heading": "Transkription läuft...",
"body": "Die Transkription deines Meetings läuft. Du erhältst das Ergebnis per E-Mail, sobald das Meeting beendet ist.",
"button": "Transkription stoppen"
},
"stopping": {
"heading": "",
"body": ""
"heading": "Daten werden gespeichert…",
"body": "Dieser Vorgang kann einige Minuten dauern. Danke für deine Geduld."
},
"beta": {
"heading": "",
"body": "",
"button": ""
"heading": "Werde Beta-Tester",
"body": "Zeichne dein Meeting auf. Du erhältst eine Zusammenfassung per E-Mail nach dem Meeting.",
"button": "Anmelden"
},
"alert": {
"title": "Transkription fehlgeschlagen",
"body": {
"stop": "Die Transkription konnte nicht gestoppt werden. Bitte versuche es in einem Moment erneut.",
"start": "Die Transkription konnte nicht gestartet werden. Bitte versuche es in einem Moment erneut."
},
"button": "OK"
}
},
"screenRecording": {
"start": {
"heading": "",
"body": "",
"button": "",
"linkMore": ""
"heading": "Dieses Gespräch aufzeichnen",
"body": "Zeichne dieses Gespräch auf, um es später anzusehen. Du erhältst die Videoaufnahme per E-Mail.",
"button": "Aufzeichnung starten",
"loading": "Aufzeichnung wird gestartet",
"linkMore": "Mehr erfahren"
},
"stopping": {
"heading": "",
"body": ""
"heading": "Daten werden gespeichert…",
"body": "Dieser Vorgang kann einige Minuten dauern. Danke für deine Geduld."
},
"stop": {
"heading": "",
"body": "",
"button": ""
"heading": "Aufzeichnung läuft…",
"body": "Du erhältst das Ergebnis per E-Mail, sobald die Aufzeichnung abgeschlossen ist.",
"button": "Aufzeichnung stoppen"
},
"alert": {
"title": "Aufzeichnung fehlgeschlagen",
"body": {
"stop": "Die Aufzeichnung konnte nicht gestoppt werden. Bitte versuche es in einem Moment erneut.",
"start": "Die Aufzeichnung konnte nicht gestartet werden. Bitte versuche es in einem Moment erneut."
},
"button": "OK"
}
},
"admin": {
"description": "",
"description": "Diese Einstellungen für Organisatoren ermöglichen dir die Kontrolle über dein Meeting. Nur Organisatoren haben Zugriff auf diese Optionen.",
"access": {
"title": "",
"description": "",
"type": "",
"title": "Raumzugang",
"description": "Diese Einstellungen gelten auch für zukünftige Ausgaben dieses Meetings.",
"type": "Zugangsarten für Meetings",
"levels": {
"public": {
"label": "",
"description": ""
"label": "Offen",
"description": "Niemand muss um Zugang zum Meeting bitten."
},
"trusted": {
"label": "",
"description": ""
"label": "Offen für vertrauenswürdige Personen",
"description": "Authentifizierte Personen müssen nicht um Zugang bitten."
},
"restricted": {
"label": "",
"description": ""
"label": "Eingeschränkt",
"description": "Nicht eingeladene Personen müssen um Zugang bitten."
}
}
}
},
"rating": {
"submit": "",
"question": "",
"submit": "Absenden",
"question": "Wie bewertest du die Qualität deines Gesprächs?",
"levels": {
"min": "",
"max": ""
"min": "sehr schlecht",
"max": "ausgezeichnet"
}
},
"openFeedback": {
"question": "",
"placeholder": "",
"submit": "",
"skip": ""
"question": "Was können wir tun, um Visio zu verbessern?",
"placeholder": "Beschreibe deine Fehler oder teile deine Vorschläge…",
"submit": "Absenden",
"skip": "Überspringen"
},
"confirmationMessage": {
"heading": "",
"body": ""
"heading": "Vielen Dank für deine Rückmeldung",
"body": "Unser Produktteam nimmt sich Zeit, dein Feedback sorgfältig zu prüfen. Wir melden uns so bald wie möglich bei dir."
},
"authenticationMessage": {
"heading": "",
"placeholder": "",
"submit": "",
"ignore": ""
"heading": "Wie können wir dich kontaktieren?",
"placeholder": "Deine E-Mail-Adresse",
"submit": "Senden",
"ignore": "Ignorieren"
},
"participants": {
"subheading": "",
"contributors": "",
"subheading": "Im Raum",
"you": "Du",
"contributors": "Mitwirkende",
"collapsable": {
"open": "",
"close": ""
"open": "{{name}}-Liste öffnen",
"close": "{{name}}-Liste schließen"
},
"you": "",
"muteYourself": "",
"muteParticipant": "",
"muteYourself": "Mikrofon ausschalten",
"muteParticipant": "{{name}}s Mikrofon ausschalten",
"muteParticipantAlert": {
"heading": "",
"description": "",
"confirm": "",
"cancel": ""
"heading": "{{name}} stummschalten",
"description": "{{name}} für alle Teilnehmer stummschalten? {{name}} kann sich nur selbst wieder einschalten.",
"confirm": "Stummschalten",
"cancel": "Abbrechen"
},
"raisedHands": "",
"lowerParticipantHand": "",
"lowerParticipantsHand": "",
"raisedHands": "Meldungen",
"lowerParticipantHand": "{{name}}s Hand senken",
"lowerParticipantsHand": "Alle Hände senken",
"waiting": {
"title": "",
"title": "Warteraum",
"accept": {
"button": "",
"label": "",
"all": ""
"button": "Zulassen",
"label": "{{name}} zum Meeting zulassen",
"all": "Alle zulassen"
},
"deny": {
"button": "",
"label": "",
"all": ""
"button": "Ablehnen",
"label": "{{name}} vom Meeting ablehnen",
"all": "Alle ablehnen"
}
}
},
"recordingBadge": {
"recordingStateToast": {
"transcript": {
"started": "",
"starting": "",
"stopping": ""
"started": "Transkription läuft",
"starting": "Transkription wird gestartet",
"stopping": "Transkription wird gestoppt"
},
"screenRecording": {
"started": "",
"starting": "",
"stopping": ""
"started": "Aufzeichnung läuft",
"starting": "Aufzeichnung wird gestartet",
"stopping": "Aufzeichnung wird gestoppt"
},
"any": {
"started": ""
"started": "Aufzeichnung läuft"
}
},
"participantTileFocus": {
"pin": {
"enable": "",
"disable": ""
"enable": "Anheften",
"disable": "Lösen"
},
"effects": "",
"muteParticipant": "",
"fullScreen": ""
"effects": "Visuelle Effekte anwenden",
"muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild"
},
"fullScreenWarning": {
"message": "",
"stop": "",
"ignore": ""
"message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
"stop": "Präsentation beenden",
"ignore": "Ignorieren"
},
"participantTile": {
"screenShare": "{{name}}s Bildschirm"
}
}
+6 -6
View File
@@ -1,10 +1,10 @@
{
"createMeeting": {
"createButton": "",
"joinButton": "",
"copyLinkTooltip": "",
"resetLabel": "",
"participantLimit": "",
"popupBlocked": ""
"createButton": "Erstellen",
"joinButton": "Mit Visio beitreten",
"copyLinkTooltip": "Link kopieren",
"resetLabel": "Zurücksetzen",
"participantLimit": "Bis zu 150 Teilnehmer.",
"popupBlocked": "Popup wurde blockiert. Bitte erlauben Sie Popups für diese Website."
}
}

Some files were not shown because too many files have changed in this diff Show More