Compare commits

..

54 Commits

Author SHA1 Message Date
lebaudantoine 9310bdf36a 🔧(helm) update ASR model name after switch to WhisperX
Correct Automatic Speech Recognition model naming configuration to reflect
the transition from insanely-fast-whisper to WhisperX implementation.
2025-05-16 19:17:45 +02:00
lebaudantoine 21ed460783 🌐(backend) improve French translations for technical terminology
Refine French translations to be less literal and preserve English terms for
standard technical concepts. Enhances clarity and maintains industry
terminology conventions.
2025-05-16 19:16:01 +02:00
lebaudantoine d562a1fcae 🌐(backend) add missing translations for room PIN functionality
Add internationalization support for previously untranslated strings related
to room PIN code logic. Ensures consistent localization across all user-
facing room access features.
2025-05-16 19:11:51 +02:00
lebaudantoine 3e93f5924c (backend) add 10-digit PIN codes on rooms for telephony
Enable users to join rooms via SIP telephony by:
- Dialing the SIP trunk number
- Entering the room's PIN followed by '#'

The PIN code needs to be generated before the LiveKit room is created,
allowing the owner to send invites to participants in advance.

With 10-digit PINs (10^10 combinations) and a large number of rooms
(e.g., 1M), collisions become statistically inevitable. A retry mechanism
helps reduce the chance of repeated collisions but doesn't eliminate
the overall risk.

With 100K generated PINs, the probability of at least one collision exceeds
39%, due to the birthday paradox.

To scale safely, we’ll later propose using multiple trunks. Each trunk
will handle a separate PIN namespace, and the combination of trunk_id and PIN
will ensure uniqueness. Room assignment will be evenly distributed across
trunks to balance load and minimize collisions.

Following XP principles, we’ll ship the simplest working version of this
feature. The goal is to deliver value quickly without over-engineering.

We’re not solving scaling challenges we don’t currently face.
Our production load is around 10,000 rooms — well within safe limits for
the initial implementation.

Discussion points:
- The `while` loop should be reviewed. Should we add rate limiting
  for failed attempts?
- A systematic existence check before `INSERT` is more costly for a rare
  event and doesn't prevent race conditions, whereas retrying on integrity
  errors is more efficient overall.
- Should we add logging or monitoring to track and analyze collisions?

I tried to balance performance and simplicity while ensuring the
robustness of the PIN generation process.
2025-05-15 17:17:55 +02:00
Jacques ROUSSEL d70dc41643 ️(tilt) fix cp for linux users
Fix the cp permission issue for linux users
2025-05-15 17:17:07 +02:00
lebaudantoine 6e81b55403 (frontend) add prayer hands emoji reaction
Implement new 🙏 reaction in response to user requests for more diverse
emotional expression options.

Requested by a beta user.
2025-05-15 15:44:06 +02:00
keda82 b8cc21debc 📝(backend) improve deployment documentation with missing prerequisites
Add critical setup requirements including kubectl installation, script download
instructions, executable permissions for mkcert and Docker, and clarify
local-only access limitation.
2025-05-15 15:20:57 +02:00
lebaudantoine 36ddb84982 🐛(backend) fix ingress path to use specific API path
Replace generic '/api' path with versioned '/api/v' pattern in Helm
ingress template to ensure proper routing for backend requests.

It closes #539
2025-05-15 14:57:50 +02:00
lebaudantoine 496ae12fa9 ♻️(backend) remove lazy from languages field on User model
The idea behind wrapping choices in `lazy` function was to allow
overriding the list of languages in tests with `override_settings`.
This was causing makemigrations to keep on including the field in
migrations when it is not needed. Since we finally don't override
the LANGUAGES setting in tests, we can remove it to fix the problem.

Taken from docs #c882f13
2025-05-15 13:50:25 +02:00
lebaudantoine ae4ef48d05 ♻️(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:08:41 +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
113 changed files with 4599 additions and 4506 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">
+1 -1
View File
@@ -49,7 +49,7 @@ clean_old_images('localhost:5001/meet-summary')
# This is necessary because we need to inject the certificate into our LiveKit container
local_resource(
'copy-root-ca',
cmd='cp "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
cmd='cp -f "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
deps=[], # No dependencies needed
)
# Build a custom LiveKit Docker image that includes our root CA certificate
+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}
+19 -7
View File
@@ -2,7 +2,7 @@
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
## Prerequisites
## Prerequisites for a kubernetes setup
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
@@ -12,14 +12,25 @@ This document is a step-by-step guide that describes how to install Visio on a k
### Test cluster
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
To be able to use the script, you will need to install:
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
To be able to use the script, you will need to install the following components:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
- kubectl (https://kubernetes.io/docs/tasks/tools/)
In order to initiate the local kind installation via **start-kind.sh** do the following:
1) Make sure administrator/root user context is able to execute mkcert, docker, kind etc. commands or the script might fail
2) Download the script to the device where the above components are installed
3) Make the script executable
4) Run the script with proper permissions (administrator/sudo etc.)
The output of the script will resemble the below example:
```
$ ./bin/start-kind.sh
@@ -99,7 +110,7 @@ When your k8s cluster is ready, you can start the deployment. This cluster is sp
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation
## Preparation of components
### What will you use to authenticate your users ?
@@ -111,7 +122,7 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
$ kubectl create namespace meet
```
If you have already run the script, you can skip this step and proceed to the next instruction.
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
```
$ kubectl config set-context --current --namespace=meet
@@ -232,7 +243,7 @@ meet <none> meet.127.0.0.1.nip.io localhost 80, 44
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
```
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
You can use Visio on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
## All options
@@ -287,6 +298,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 +313,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 +321,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 |
+5 -1
View File
@@ -48,8 +48,12 @@ 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
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
# Telephony
ROOM_TELEPHONY_ENABLED=True
+1 -1
View File
@@ -120,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,
+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,45 @@
# Generated by Django 5.1.9 on 2025-05-13 08:22
import secrets
from django.db import migrations, models
def generate_pin_for_rooms(apps, schema_editor):
"""Generate unique 10-digit PIN codes for existing rooms.
The PIN code is required for SIP telephony features.
"""
Room = apps.get_model('core', 'Room')
rooms_without_pin_code = Room.objects.filter(pin_code__isnull=True)
existing_pins = set(Room.objects.values_list('pin_code', flat=True))
length = 10
def generate_pin_code():
while True:
pin_code = str(secrets.randbelow(10 ** length)).zfill(length)
if not pin_code in existing_pins:
return pin_code
for room in rooms_without_pin_code:
room.pin_code = generate_pin_code()
room.save()
class Migration(migrations.Migration):
dependencies = [
('core', '0013_alter_user_language'),
]
operations = [
migrations.AddField(
model_name='room',
name='pin_code',
field=models.CharField(blank=True, help_text='Unique n-digit code that identifies this room in telephony mode.', max_length=None, null=True, unique=True, verbose_name='Room PIN code'),
),
migrations.RunPython(
generate_pin_for_rooms,
reverse_code=migrations.RunPython.noop
),
]
+44 -2
View File
@@ -2,6 +2,7 @@
Declare and configure the models for the Meet core application
"""
import secrets
import uuid
from datetime import datetime, timedelta
from logging import getLogger
@@ -14,7 +15,6 @@ 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 _
@@ -164,7 +164,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
choices=settings.LANGUAGES,
default=settings.LANGUAGE_CODE,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
@@ -383,6 +383,14 @@ class Room(Resource):
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
pin_code = models.CharField(
max_length=None,
unique=True,
blank=True,
null=True,
verbose_name=_("Room PIN code"),
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
)
class Meta:
db_table = "meet_room"
@@ -393,6 +401,14 @@ class Room(Resource):
def __str__(self):
return capfirst(self.name)
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
super().save(*args, **kwargs)
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -407,6 +423,7 @@ class Room(Resource):
pass
else:
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude)
@property
@@ -414,6 +431,31 @@ class Room(Resource):
"""Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC
@staticmethod
def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code"""
if length < 4:
raise ValueError(
"PIN code length must be at least 4 digits for minimal security"
)
max_value = 10**length
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
pin_code = str(secrets.randbelow(max_value)).zfill(length)
if not Room.objects.filter(pin_code=pin_code).exists():
return pin_code
# Log a warning as a temporary measure until backend observability is implemented.
logger.warning(
"Failed to generate unique PIN code of length %s after %s attempts",
length,
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES,
)
return None
class BaseAccessManager(models.Manager):
"""Base manager for handling resource access control."""
@@ -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
@@ -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")
@@ -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"
@@ -338,22 +338,20 @@ def test_api_rooms_retrieve_authenticated():
)
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}/",
)
@@ -361,37 +359,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
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,
"timezone": "UTC",
"language": user_access.user.language,
},
"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,
"timezone": "UTC",
"language": other_user_access.user.language,
},
"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 == {
+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()
+172
View File
@@ -2,6 +2,12 @@
Unit tests for the Room model
"""
# pylint: disable=W0613
import secrets
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -175,3 +181,169 @@ def test_models_rooms_is_public_property():
# Test non-public room
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
assert private_room.is_public is False
@mock.patch.object(Room, "generate_unique_pin_code")
def test_telephony_disabled_skips_pin_generation(
mock_generate_unique_pin_code, settings
):
"""Telephony disabled should not generate pin codes."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
mock_generate_unique_pin_code.assert_not_called()
assert room.pin_code is None
def test_default_and_custom_pin_length(settings):
"""Pin codes should be created with correct configured length."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
# Assert default value is 10 for collision reasons
assert len(room.pin_code) == 10
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
room = RoomFactory()
# Assert custom size
assert len(room.pin_code) == 5
def test_room_updates_preserve_pin_code(settings):
"""Room updates should preserve existing pin code."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
# Store the original pin code to compare after updates
previous_pin_code = room.pin_code
# If this method is called, it would indicate the pin is being regenerated unnecessarily
with mock.patch.object(
Room, "generate_unique_pin_code"
) as mock_generate_unique_pin_code:
# Explicitly call save to persist the changes to the room
room.slug = "aaa-aaaa-aaa"
room.save()
assert room.pin_code == previous_pin_code
mock_generate_unique_pin_code.assert_not_called()
@pytest.mark.parametrize("is_telephony_enabled", [True, False])
def test_manual_pin_code_updates(is_telephony_enabled, settings):
"""Manual pin code changes should persist regardless of telephony setting."""
settings.ROOM_TELEPHONY_ENABLED = is_telephony_enabled
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
room = RoomFactory()
assert room.pin_code != "12345"
room.pin_code = "12345"
room.save()
assert room.pin_code == "12345"
# No data validation when manual updates are made
room.pin_code = "123"
room.save()
assert room.pin_code == "123"
def test_pin_code_uniqueness(settings):
"""Duplicate pin codes should raise validation error."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
RoomFactory(pin_code="12345")
with pytest.raises(ValidationError) as excinfo:
RoomFactory(pin_code="12345")
assert "Room with this Room PIN code already exists." in str(excinfo.value)
@pytest.mark.parametrize("invalid_length", [0, 1, 2, 3])
def test_pin_code_minimum_length(invalid_length, settings):
"""Pin codes should enforce minimum length for security."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 4
# Assert no exception is raised with a valid length
RoomFactory()
settings.ROOM_TELEPHONY_PIN_LENGTH = invalid_length
with pytest.raises(ValueError) as excinfo:
RoomFactory()
assert "PIN code length must be at least 4 digits for minimal security" in str(
excinfo.value
)
@mock.patch.object(Logger, "warning")
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_generation_max_retries(mock_randbelow, mock_logger, settings):
"""Pin generation should give up after max retries."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
RoomFactory(pin_code="12345")
# Assert default max retries is low, 3
room1 = RoomFactory()
assert mock_randbelow.call_count == 3
assert room1.pin_code is None
mock_logger.assert_called_once_with(
"Failed to generate unique PIN code of length %s after %s attempts", 5, 3
)
mock_logger.reset_mock()
mock_randbelow.reset_mock()
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES = 5
room2 = RoomFactory()
assert mock_randbelow.call_count == 5
assert room2.pin_code is None
mock_logger.assert_called_once_with(
"Failed to generate unique PIN code of length %s after %s attempts", 5, 5
)
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_code_zero_padding(mock_randbelow, settings):
"""Pin codes should be zero-padded to meet required length."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
room = RoomFactory()
assert room.pin_code == "0000012345"
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_generation_upper_bound(mock_randbelow, settings):
"""Random number generator should use correct upper bound based on pin length."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
room.generate_unique_pin_code(length=5)
# Assert called with the right exclusive upper bound, 10^5
mock_randbelow.assert_called_with(100000)
+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,438 @@
# 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-16 17:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "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:391
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Raum"
#: core/models.py:399
msgid "Rooms"
msgstr "Räume"
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:565
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:573
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:580
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:581
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:708
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.
+73 -89
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
"POT-Creation-Date: 2025-05-16 17:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -33,107 +33,95 @@ msgstr "Important dates"
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr "User info contained no recognizable user identification"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "User account is disabled"
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Multiple user accounts share a common email."
#: core/models.py:30
#: core/models.py:31
msgid "Member"
msgstr "Member"
#: core/models.py:31
#: core/models.py:32
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:32
#: core/models.py:33
msgid "Owner"
msgstr "Owner"
#: core/models.py:48
#: core/models.py:49
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:49
#: core/models.py:50
msgid "Active"
msgstr "Active"
#: core/models.py:50
#: core/models.py:51
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:51
#: core/models.py:52
msgid "Saved"
msgstr "Saved"
#: core/models.py:52
#: core/models.py:53
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:53
#: core/models.py:54
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:54
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:55
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:82
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:83
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:89
#: core/models.py:90
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:90
#: core/models.py:91
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:91
#: core/models.py:92
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:103
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:104
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:110
#: core/models.py:111
msgid "created on"
msgstr "created on"
#: core/models.py:111
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:116
#: core/models.py:117
msgid "updated on"
msgstr "updated on"
#: core/models.py:117
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:137
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -141,11 +129,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:143
#: core/models.py:144
msgid "sub"
msgstr "sub"
#: core/models.py:145
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -153,55 +141,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:153
#: core/models.py:154
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:158
#: core/models.py:159
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:160
#: core/models.py:161
msgid "full name"
msgstr "full name"
#: core/models.py:162
#: core/models.py:163
msgid "short name"
msgstr "short name"
#: core/models.py:168
#: core/models.py:169
msgid "language"
msgstr "language"
#: core/models.py:169
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:175
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:178
#: core/models.py:179
msgid "device"
msgstr "device"
#: core/models.py:180
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:183
#: core/models.py:184
msgid "staff status"
msgstr "staff status"
#: core/models.py:185
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:188
#: core/models.py:189
msgid "active"
msgstr "active"
#: core/models.py:191
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -209,55 +197,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:204
#: core/models.py:205
msgid "user"
msgstr "user"
#: core/models.py:205
#: core/models.py:206
msgid "users"
msgstr "users"
#: core/models.py:264
#: core/models.py:265
msgid "Resource"
msgstr "Resource"
#: core/models.py:265
#: core/models.py:266
msgid "Resources"
msgstr "Resources"
#: core/models.py:319
#: core/models.py:320
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:320
#: core/models.py:321
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:326
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:382
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:383
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:389 core/models.py:509
#: core/models.py:391
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Room"
#: core/models.py:390
#: core/models.py:399
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:520
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:522
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -265,58 +261,42 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:530
#: core/models.py:573
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:531
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:537
#: core/models.py:580
msgid "Recording"
msgstr "Recording"
#: core/models.py:538
#: core/models.py:581
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:646
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:647
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:653
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:659
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:665
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "Authentication is enabled but token is not configured."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "Authorization header is required"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "Invalid authorization header."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Invalid token"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -447,3 +427,7 @@ msgstr "French"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:165
msgid "German"
msgstr "German"
Binary file not shown.
+77 -95
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
"POT-Creation-Date: 2025-05-16 17:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -35,123 +35,109 @@ 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 ""
"Les informations utilisateur ne contiennent aucune identification "
"utilisateur reconnaissable"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "Le compte utilisateur est désactivé"
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Plusieurs comptes utilisateur partagent une adresse e-mail commune."
#: core/models.py:30
#: core/models.py:31
msgid "Member"
msgstr "Membre"
#: core/models.py:31
#: core/models.py:32
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:32
#: core/models.py:33
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:48
#: core/models.py:49
msgid "Initiated"
msgstr "Initié"
#: core/models.py:49
#: core/models.py:50
msgid "Active"
msgstr "Actif"
#: core/models.py:50
#: core/models.py:51
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:51
#: core/models.py:52
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:52
#: core/models.py:53
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:53
#: core/models.py:54
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:54
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:55
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:82
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:83
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:89
#: core/models.py:90
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:90
#: core/models.py:91
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:91
#: core/models.py:92
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:103
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:104
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:110
#: core/models.py:111
msgid "created on"
msgstr "créé le"
#: core/models.py:111
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:116
#: core/models.py:117
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:117
#: 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:137
#: 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, "
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, "
"des chiffres et les caractères @/./+/-/_."
#: core/models.py:143
#: core/models.py:144
msgid "sub"
msgstr "identifiant"
msgstr "sub"
#: core/models.py:145
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -159,55 +145,55 @@ msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:153
#: core/models.py:154
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:158
#: core/models.py:159
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:160
#: core/models.py:161
msgid "full name"
msgstr "nom complet"
#: core/models.py:162
#: core/models.py:163
msgid "short name"
msgstr "nom court"
#: core/models.py:168
#: core/models.py:169
msgid "language"
msgstr "langue"
#: core/models.py:169
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:175
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:178
#: core/models.py:179
msgid "device"
msgstr "appareil"
#: core/models.py:180
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:183
#: core/models.py:184
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:185
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:188
#: core/models.py:189
msgid "active"
msgstr "actif"
#: core/models.py:191
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -215,116 +201,108 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:204
#: core/models.py:205
msgid "user"
msgstr "utilisateur"
#: core/models.py:205
#: core/models.py:206
msgid "users"
msgstr "utilisateurs"
#: core/models.py:264
#: core/models.py:265
msgid "Resource"
msgstr "Ressource"
#: core/models.py:265
#: core/models.py:266
msgid "Resources"
msgstr "Ressources"
#: core/models.py:319
#: core/models.py:320
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:320
#: core/models.py:321
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:326
#: 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:382
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:383
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:389 core/models.py:509
#: core/models.py:391
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Salle"
#: core/models.py:390
#: core/models.py:399
msgid "Rooms"
msgstr "Salles"
#: core/models.py:520
#: core/models.py:563
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:522
#: core/models.py:565
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 "
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant "
"est conservé même lorsque le Worker s'arrête, permettant un suivi "
"facile."
#: core/models.py:530
#: core/models.py:573
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:531
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:537
#: core/models.py:580
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:538
#: core/models.py:581
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:646
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:647
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:653
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:659
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:665
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "L'authentification est activée mais le jeton n'est pas configuré."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "L'en-tête d'autorisation est requis"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "En-tête d'autorisation invalide."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Jeton invalide"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -455,3 +433,7 @@ msgstr "Français"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:165
msgid "German"
msgstr "Allemand"
Binary file not shown.
+73 -89
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
"POT-Creation-Date: 2025-05-16 17:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -34,107 +34,95 @@ msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr "Gebruikersinformatie bevatte geen herkenbare gebruikersidentificatie"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "Gebruikersaccount is uitgeschakeld"
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Meerdere gebruikersaccounts delen een gemeenschappelijk e-mailadres."
#: core/models.py:30
#: core/models.py:31
msgid "Member"
msgstr "Lid"
#: core/models.py:31
#: core/models.py:32
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:32
#: core/models.py:33
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:48
#: core/models.py:49
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:49
#: core/models.py:50
msgid "Active"
msgstr "Actief"
#: core/models.py:50
#: core/models.py:51
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:51
#: core/models.py:52
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:52
#: core/models.py:53
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:53
#: core/models.py:54
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:54
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:55
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:82
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:83
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:89
#: core/models.py:90
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:90
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:91
#: core/models.py:92
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:103
#: core/models.py:104
msgid "id"
msgstr "id"
#: core/models.py:104
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:110
#: core/models.py:111
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:111
#: 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:116
#: core/models.py:117
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:117
#: 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:137
#: core/models.py:138
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -142,66 +130,66 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:143
#: core/models.py:144
msgid "sub"
msgstr "sub"
#: core/models.py:145
#: 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:153
#: core/models.py:154
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:158
#: core/models.py:159
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:160
#: core/models.py:161
msgid "full name"
msgstr "volledige naam"
#: core/models.py:162
#: core/models.py:163
msgid "short name"
msgstr "korte naam"
#: core/models.py:168
#: core/models.py:169
msgid "language"
msgstr "taal"
#: core/models.py:169
#: 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:175
#: 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:178
#: core/models.py:179
msgid "device"
msgstr "apparaat"
#: core/models.py:180
#: 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:183
#: core/models.py:184
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:185
#: 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:188
#: core/models.py:189
msgid "active"
msgstr "actief"
#: core/models.py:191
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -209,55 +197,63 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:204
#: core/models.py:205
msgid "user"
msgstr "gebruiker"
#: core/models.py:205
#: core/models.py:206
msgid "users"
msgstr "gebruikers"
#: core/models.py:264
#: core/models.py:265
msgid "Resource"
msgstr "Bron"
#: core/models.py:265
#: core/models.py:266
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:319
#: core/models.py:320
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:320
#: core/models.py:321
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:326
#: 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:382
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:383
#: 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:389 core/models.py:509
#: core/models.py:391
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Ruimte"
#: core/models.py:390
#: core/models.py:399
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:520
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:522
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -265,58 +261,42 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:530
#: core/models.py:573
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:531
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:537
#: core/models.py:580
msgid "Recording"
msgstr "Opname"
#: core/models.py:538
#: core/models.py:581
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:646
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:647
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:653
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:659
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:665
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "Authenticatie is ingeschakeld maar token is niet geconfigureerd."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "Autorisatie-header is vereist"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "Ongeldige autorisatie-header."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Ongeldig token"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -447,3 +427,7 @@ msgstr "Frans"
#: meet/settings.py:164
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:165
msgid "German"
msgstr "Duits"
+37 -5
View File
@@ -162,6 +162,7 @@ class Base(Configuration):
("en-us", _("English")),
("fr-fr", _("French")),
("nl-nl", _("Dutch")),
("de-de", _("German")),
)
)
@@ -263,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,
@@ -312,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
@@ -356,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
)
@@ -391,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
)
@@ -437,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 = {
@@ -446,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
)
@@ -460,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",
@@ -558,6 +573,23 @@ class Base(Configuration):
environ_prefix=None,
)
# SIP Telephony
ROOM_TELEPHONY_ENABLED = values.BooleanValue(
False,
environ_name="ROOM_TELEPHONY_ENABLED",
environ_prefix=None,
)
ROOM_TELEPHONY_PIN_LENGTH = values.PositiveIntegerValue(
10,
environ_name="ROOM_TELEPHONY_PIN_LENGTH",
environ_prefix=None,
)
ROOM_TELEPHONY_PIN_MAX_RETRIES = values.PositiveIntegerValue(
5,
environ_name="ROOM_TELEPHONY_PIN_MAX_RETRIES",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
+4 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.19"
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.19",
"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.6",
"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: 11 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>
+3
View File
@@ -11,6 +11,9 @@ export interface ApiConfig {
support?: {
id: string
}
feedback: {
url: string
}
silence_livekit_debug_logs?: boolean
is_silent_login_enabled?: boolean
recording?: {
@@ -2,15 +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'
import { useSyncUserPreferencesWithBackend } from '@/features/auth'
const SDK_BASE_ROUTE = '/sdk'
export const AppInitialization = () => {
const { data } = useConfig()
const [location] = useLocation()
useSyncUserPreferencesWithBackend()
const {
analytics = {},
@@ -18,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" />
@@ -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)
}
@@ -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,
@@ -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>
)
}
@@ -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}
>
@@ -12,9 +12,19 @@ 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',
PLEASE = 'folded-hands',
}
export interface Reaction {
id: number
@@ -56,6 +66,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 +121,10 @@ export const ReactionsToggle = () => {
<div
className={css({
position: 'absolute',
top: -55,
left: -114,
top: -63,
left: -162,
borderRadius: '8px',
padding: '0.25rem',
padding: '0.35rem',
backgroundColor: 'primaryDark.50',
opacity: opacity,
transition: 'opacity 0.2s ease',
@@ -122,24 +138,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."
}
}
+12 -12
View File
@@ -1,24 +1,24 @@
{
"error": {
"title": "",
"body": ""
"title": "Aufzeichnung nicht verfügbar",
"body": "Diese Aufzeichnung konnte nicht gefunden werden oder wurde gelöscht."
},
"expired": {
"title": "",
"body": ""
"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": "",
"expiration": "",
"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."
}
}
+27 -27
View File
@@ -1,49 +1,49 @@
{
"account": {
"currentlyLoggedAs": "",
"heading": "",
"youAreNotLoggedIn": "",
"nameLabel": "",
"authentication": ""
"currentlyLoggedAs": "Sie sind derzeit angemeldet als <0>{{user}}</0>",
"heading": "Konto",
"youAreNotLoggedIn": "Sie sind nicht angemeldet.",
"nameLabel": "Ihr Name",
"authentication": "Authentifizierung"
},
"audio": {
"microphone": {
"heading": "",
"label": ""
"heading": "Mikrofon",
"label": "Wählen Sie Ihre Audioeingabe"
},
"speakers": {
"heading": "",
"label": "",
"test": "",
"ongoingTest": ""
"heading": "Lautsprecher",
"label": "Wählen Sie Ihre Audioausgabe",
"test": "Testen",
"ongoingTest": "Soundtest läuft…"
},
"permissionsRequired": ""
"permissionsRequired": "Berechtigungen erforderlich"
},
"notifications": {
"heading": "",
"label": "",
"heading": "Tonbenachrichtigungen",
"label": "Tonbenachrichtigungen für",
"actions": {
"disable": "",
"enable": ""
"disable": "Deaktivieren",
"enable": "Aktivieren"
},
"items": {
"participantJoined": "",
"handRaised": "",
"messageReceived": ""
"participantJoined": "Teilnehmer beigetreten",
"handRaised": "Hand gehoben",
"messageReceived": "Nachricht erhalten"
}
},
"dialog": {
"heading": ""
"heading": "Einstellungen"
},
"language": {
"heading": "",
"label": ""
"heading": "Sprache",
"label": "Sprache"
},
"settingsButtonLabel": "",
"settingsButtonLabel": "Einstellungen",
"tabs": {
"account": "",
"audio": "",
"general": "",
"notifications": ""
"account": "Profil",
"audio": "Audio",
"general": "Allgemein",
"notifications": "Benachrichtigungen"
}
}
+56 -34
View File
@@ -1,72 +1,94 @@
{
"title": "",
"title": "Nutzungsbedingungen für Visio",
"articles": {
"article1": {
"title": "",
"content": ""
"title": "1. Geltungsbereich",
"content": "Dieses Dokument definiert die Nutzungsbedingungen für Visio, den Videokonferenzdienst der Verwaltung."
},
"article2": {
"title": "",
"content": "",
"purposes": ""
"title": "2. Zweck der Plattform",
"content": "Der Dienst fördert die Zusammenarbeit im Team und das Arbeiten aus der Ferne und erleichtert die Organisation von Besprechungen, Konferenzen oder Schulungen.",
"purposes": "Visio wird von DINUM entwickelt und betrieben. Jede Nutzung des Dienstes muss mit diesen Nutzungsbedingungen übereinstimmen."
},
"article3": {
"title": "",
"definition": ""
"title": "3. Zugang zum Dienst",
"definition": "Visio wird öffentlichen Bediensteten zur Verfügung gestellt, die über ProConnect verbunden sind, um einen Videokonferenzraum zu verwalten. Der verbundene öffentliche Bedienstete kann dann einen Link übermitteln, der den Zugang zum Raum unter seiner Verantwortung ermöglicht."
},
"article4": {
"title": "",
"content": ""
"title": "4. Nutzung",
"content": "Die Nutzung des Dienstes ist kostenlos. Um einen Videokonferenzraum zu verwalten, ist eine Verbindung über ProConnect erforderlich."
},
"article5": {
"title": "",
"title": "5. Funktionen",
"sections": {
"section1": {
"title": "",
"content": "",
"paragraph1": "",
"paragraph2": "",
"capabilities": ["", "", "", ""],
"paragraph3": ""
"title": "5.1. Funktionen für öffentliche Bedienstete",
"content": "Visio bietet öffentlichen Bediensteten die Möglichkeit, von einem Standardarbeitsplatz aus einen Videokonferenzraum zu erstellen, der mit ihrem ProConnect-Konto verknüpft ist.",
"paragraph1": "Jeder Staatsbedienstete, dessen Tätigkeiten mit der Nutzung von Visio vereinbar sind oder der keinen spezifischen Vertraulichkeitsverpflichtungen oder dem Berufsgeheimnis unterliegt, kann eine Videokonferenz erstellen. Er kann dann den Besprechungslink mit anderen Teilnehmern teilen, unabhängig davon, ob sie Teil der Verwaltung sind oder nicht.",
"paragraph2": "Zu diesem Zweck kann jeder Raumorganisator:",
"capabilities": [
"Den Zugang zum Raum mit einem Warteraum schützen;",
"Den Eintritt jedes Teilnehmers in den Raum autorisieren;",
"Die Mikrofone und Kameras der Teilnehmer stummschalten;",
"Einen Teilnehmer aus dem Raum entfernen."
],
"paragraph3": "Nur Bedienstete, deren Tätigkeitsbereich zur Nutzung von Visio berechtigt ist oder die <u>keinen spezifischen Vertraulichkeitsverpflichtungen oder dem Berufsgeheimnis unterliegen</u>, dürfen diesen Dienst und seine Funktionen nutzen oder kontrollieren."
},
"section2": {
"title": "",
"content": "",
"paragraph": "",
"capabilities": ["", "", "", "", "", "", ""]
"title": "5.2. Funktionen für Nutzer",
"content": "Visio ermöglicht es jedem Nutzer, ob öffentlicher Bediensteter oder nicht, an jedem Visio-Raum teilzunehmen, zu dem er von einem Staatsbediensteten eingeladen wurde, und alle Funktionen zu nutzen.",
"paragraph": "Zu diesem Zweck kann jeder Teilnehmer:",
"capabilities": [
"Seine angezeigte Identität beim Betreten des Raums festlegen;",
"Über Audio und/oder Video aus der Ferne kommunizieren;",
"Seinen Bildschirm teilen;",
"Während der Sitzung öffentlich mit allen Nachrichten austauschen;",
"Informationen über die Konferenz einsehen;",
"Seine Kamera und/oder sein Mikrofon jederzeit ein- oder ausschalten;",
"Die Privatsphäre seines Arbeitsplatzes schützen, indem er vor dem Betreten des Raums oder sogar während der Videokonferenz einen Hintergrund auswählt und aktiviert."
]
}
}
},
"article6": {
"title": "",
"title": "6. Verpflichtungen und Verantwortlichkeiten der Nutzer",
"sections": {
"section1": {
"title": "",
"paragraphs": ["", ""]
"title": "6.1. Konforme Nutzung",
"paragraphs": [
"Der Dienst wird öffentlichen Bediensteten über ProConnect zur Verfügung gestellt.",
"Der Nutzer ist für die Daten oder Inhalte verantwortlich, die er im Nachrichtendienst des Videokonferenzraums eingibt. Er muss sicherstellen, dass er nur angemessene Nachrichten eingibt."
]
},
"section2": {
"title": "",
"paragraphs": [""]
"title": "6.2. Verbotene Nutzung",
"paragraphs": [
"Der Nutzer verpflichtet sich, keine Inhalte oder Informationen in den Nachrichtendienst des Visio-Raums einzugeben, die gegen geltende gesetzliche und regulatorische Bestimmungen verstoßen."
]
}
}
},
"article7": {
"title": "",
"title": "7. Verpflichtungen und Verantwortlichkeiten von DINUM",
"sections": {
"section1": {
"title": "",
"content": "",
"paragraphs": ["", "", ""]
"title": "7.1. Sicherheit und Plattformzugang",
"content": "DINUM verpflichtet sich, Visio zu sichern, insbesondere durch alle notwendigen Maßnahmen zur Gewährleistung der Sicherheit und Vertraulichkeit der bereitgestellten Informationen.",
"paragraphs": [
"DINUM verpflichtet sich, die notwendigen und angemessenen Mittel bereitzustellen, um einen kontinuierlichen Zugang zu Visio zu gewährleisten.",
"DINUM behält sich das Recht vor, den Dienst aus Wartungsgründen oder aus anderen als notwendig erachteten Gründen ohne Vorankündigung zu ändern, zu modifizieren oder auszusetzen.",
"DINUM behält sich das Recht vor, ein Nutzerkonto des Dienstes zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger strafrechtlicher und zivilrechtlicher Maßnahmen, die gegen den Nutzer ergriffen werden könnten."
]
},
"section2": {
"title": "",
"content": ""
"title": "7.2. Open Source und Lizenzen",
"content": "Der Quellcode von Visio ist frei und hier verfügbar: https://github.com/suitenumerique/meet\nDie von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
}
}
},
"article8": {
"title": "",
"content": ""
"title": "8. Änderung der Nutzungsbedingungen",
"content": "Die Bedingungen dieser Nutzungsbedingungen können jederzeit ohne Vorankündigung geändert oder ergänzt werden, abhängig von Änderungen am Dienst, Gesetzesänderungen oder aus anderen als notwendig erachteten Gründen.\nDiese Änderungen und Aktualisierungen sind für den Nutzer verbindlich, der daher regelmäßig diesen Abschnitt konsultieren muss, um die geltenden Bedingungen zu überprüfen."
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"heading": "An error occurred while loading the page"
},
"feedback": {
"context": "Visio is still in early development — your input matters!",
"context": "Product under development — your input matters!",
"cta": "Share your feedback"
},
"forbidden": {
+20 -2
View File
@@ -163,8 +163,7 @@
"apply": "Add French touch",
"clear": "Remove French touch"
}
},
"experimental": "Experimental feature. A v2 is coming for full browser support and improved quality."
}
},
"sidePanel": {
"heading": {
@@ -237,6 +236,14 @@
"heading": "Become a beta tester",
"body": "Record your meeting for later. You will receive a summary by email once the meeting is finished.",
"button": "Sign up"
},
"alert": {
"title": "Transcription Failed",
"body": {
"stop": "We were unable to stop the transcription. Please try again in a moment.",
"start": "We were unable to start the transcription. Please try again in a moment."
},
"button": "OK"
}
},
"screenRecording": {
@@ -255,6 +262,14 @@
"heading": "Recording in progress…",
"body": "You will receive the result by email once the recording is complete.",
"button": "Stop recording"
},
"alert": {
"title": "Recording Failed",
"body": {
"stop": "We were unable to stop the recording. Please try again in a moment.",
"start": "We were unable to start the recording. Please try again in a moment."
},
"button": "OK"
}
},
"admin": {
@@ -364,5 +379,8 @@
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
"stop": "Stop presenting",
"ignore": "Ignore"
},
"participantTile": {
"screenShare": "{{name}}'s screen"
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"heading": "Une erreur est survenue lors du chargement de la page"
},
"feedback": {
"context": "Visio est en pleine construction — votre avis compte !",
"context": "Produit en cours de développement — votre avis compte !",
"cta": "Partagez votre avis"
},
"forbidden": {
+20 -2
View File
@@ -163,8 +163,7 @@
"apply": "Ajouter la touche française",
"clear": "Retirer la touche française"
}
},
"experimental": "Fonctionnalité expérimentale. Une v2 arrive pour un support complet des navigateurs et une meilleure qualité."
}
},
"sidePanel": {
"heading": {
@@ -237,6 +236,14 @@
"heading": "Devenez beta testeur",
"body": "Enregistrer votre réunion pour plus tard. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
"button": "Inscrivez-vous"
},
"alert": {
"title": "Échec de transcription",
"body": {
"stop": "Nous n'avons pas pu stopper la transcription. Veuillez réessayer dans quelques instants.",
"start": "Nous n'avons pas pu démarrer la transcription. Veuillez réessayer dans quelques instants."
},
"button": "OK"
}
},
"screenRecording": {
@@ -255,6 +262,14 @@
"heading": "Enregistrement en cours …",
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
"button": "Arrêter l'enregistrement"
},
"alert": {
"title": "Échec de l'enregistrement",
"body": {
"stop": "Nous n'avons pas pu stopper l'enregistrement. Veuillez réessayer dans quelques instants.",
"start": "Nous n'avons pas pu démarrer l'enregistrement. Veuillez réessayer dans quelques instants."
},
"button": "OK"
}
},
"admin": {
@@ -364,5 +379,8 @@
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
"stop": "Arrêter la présentation",
"ignore": "Ignorer"
},
"participantTile": {
"screenShare": "Écran de {{name}}"
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"heading": "Er is een fout opgetreden bij het laden van de pagina"
},
"feedback": {
"context": "Visio is nog in vroege ontwikkeling - uw input is belangrijk!",
"context": "Product in ontwikkeling - uw input is belangrijk!",
"cta": "Deel uw feedback"
},
"forbidden": {
+20 -2
View File
@@ -163,8 +163,7 @@
"apply": "Franse stijl toevoegen",
"clear": "Franse stijl verwijderen"
}
},
"experimental": "Experimentele functie. Een v2 komt eraan voor volledige browserondersteuning en verbeterde kwaliteit."
}
},
"sidePanel": {
"heading": {
@@ -237,6 +236,14 @@
"heading": "Word betatester",
"body": "Neem uw vergadering op voor later. U ontvangt een samenvatting per e-mail zodra de vergadering is afgelopen.",
"button": "Aanmelden"
},
"alert": {
"title": "Transcriptie mislukt",
"body": {
"stop": "We konden de transcriptie niet stoppen. Probeer het over enkele ogenblikken opnieuw.",
"start": "We konden de transcriptie niet starten. Probeer het over enkele ogenblikken opnieuw."
},
"button": "Opnieuw proberen"
}
},
"screenRecording": {
@@ -255,6 +262,14 @@
"heading": "Opname bezig …",
"body": "Je ontvangt het resultaat per e-mail zodra de opname is voltooid.",
"button": "Opname stoppen"
},
"alert": {
"title": "Opname mislukt",
"body": {
"stop": "We konden de opname niet stoppen. Probeer het over enkele ogenblikken opnieuw.",
"start": "We konden de opname niet starten. Probeer het over enkele ogenblikken opnieuw."
},
"button": "Opnieuw proberen"
}
},
"admin": {
@@ -364,5 +379,8 @@
"message": "Om niet oneindige uw scherm in zichzelf te delen, kunt u beter niet het hele scherm delen. Deel in plaats daarvan een tab of een ander venster.",
"stop": "Stop met presenteren",
"ignore": "Negeren"
},
"participantTile": {
"screenShare": "{{name}} scherm"
}
}
+6 -2
View File
@@ -1,4 +1,8 @@
import { FeedbackRoute, RoomRoute, roomIdPattern } from '@/features/rooms'
import {
FeedbackRoute,
RoomRoute,
flexibleRoomIdPattern,
} from '@/features/rooms'
import { HomeRoute } from '@/features/home'
import { LegalTermsRoute } from '@/features/legalsTerms/LegalTermsRoute'
import { AccessibilityRoute } from '@/features/legalsTerms/Accessibility'
@@ -32,8 +36,8 @@ export const routes: Record<
},
room: {
name: 'room',
path: new RegExp(`^[/](?<roomId>${roomIdPattern})$`),
to: (roomId: string) => `/${roomId.trim()}`,
path: new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`),
Component: RoomRoute,
},
feedback: {
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
areFunnyEffectsEnabled: boolean
}
export const konamiStore = proxy<State>({
areFunnyEffectsEnabled: false,
})
-3
View File
@@ -1,6 +1,3 @@
export const GRIST_FEEDBACKS_FORM =
'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26' as const
export const BETA_USERS_FORM_URL =
'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17' as const
@@ -55,18 +55,19 @@ backend:
ALLOW_UNREGISTERED_ROOMS: False
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}"
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: meet-media-storage
AWS_S3_REGION_NAME: local
RECORDING_ENABLE: True
RECORDING_VERIFY_SSL: True
RECORDING_STORAGE_EVENT_ENABLE: True
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN: password
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
ROOM_TELEPHONY_ENABLED: True
SSL_CERT_FILE: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
@@ -148,7 +149,7 @@ summary:
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_ASR_MODEL: large-v2
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
WEBHOOK_API_TOKEN: password
@@ -181,7 +182,7 @@ celery:
AWS_S3_SECRET_ACCESS_KEY: password
OPENAI_API_KEY: password
OPENAI_BASE_URL: https://albertine.beta.numerique.gouv.fr/v1
OPENAI_ASR_MODEL: openai/whisper-large-v3
OPENAI_ASR_MODEL: large-v2
OPENAI_LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
AWS_S3_SECURE_ACCESS: False
WEBHOOK_API_TOKEN: password

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