Compare commits

...

41 Commits

Author SHA1 Message Date
lebaudantoine b452b4c437 📦️(frontend) vendor GridLayout to use react-hook useSize
Replace livekit's modern-only useSize hook with react-hook implementation
that includes polyfill support for older browsers. Will update remaining
layout components in subsequent commits.

Also, it now enables us to fix the pagination controls.
2025-07-20 19:20:50 +02:00
lebaudantoine 1e7aeaf8ce 📦️(frontend) vendor CarouselLayout to use react-hook useSize
Replace livekit's modern-only useSize hook with react-hook implementation
that includes polyfill support for older browsers. Will update remaining
layout components in subsequent commits.
2025-07-20 19:20:50 +02:00
lebaudantoine 501cfc8d0f 🐛(frontend) use feature detection for adaptiveStream and dynacast
Replace hardcoded true values with supportsAdaptiveStream() and
supportsDynacast() checks. LiveKit SDK supports broad browser range but
requires specific APIs - modern features need explicit compatibility checks.

Prevents enabling unsupported WebRTC features on incompatible browsers,
which could led to a poor user experience.

One alternative solution could be to install polyfills.
2025-07-20 19:20:49 +02:00
lebaudantoine 0308f98ef9 ️(frontend) rollback vendored hooks and install react-hook dependency
Remove copied livekit hooks and install react-hook deps directly for better
browser support. Livekit's modern-only version causes ResizeObserver
exceptions in production. Original dependency includes necessary polyfills.
2025-07-20 19:20:05 +02:00
ericboucher 0862203d5d 🚸(frontend) add Safari warning for unavailable speaker settings
Display notice explaining that missing browser settings are due to Safari
limitations, not app bugs, to prevent user confusion.
2025-07-20 18:35:32 +02:00
ericboucher ee604abe00 🐛(frontend) replace useSize with useMediaQuery in account settings
Fix buggy layout transitions on Safari mobile by using media queries
instead of size detection for smoother settings panel responsive behavior.
2025-07-20 18:35:32 +02:00
lebaudantoine 26d668b478 🐛(frontend) prevent Crisp crash when superuser has no email
Fix crash when switching from admin session to app with superuser account
that lacks email field. Add null check to prevent Crisp initialization
errors.
2025-07-20 18:00:08 +02:00
lebaudantoine 04081f04fc 🌐(frontend) internationalize missing error message
Add translation support for previously untranslated error message to
complete localization coverage.
2025-07-20 18:00:08 +02:00
ericboucher f7268c507b 🚸(frontend) display email with username to clarify logged-in account
Show email alongside full name when available to prevent user confusion
about which account is currently logged in. Enhances general app UX.
2025-07-20 18:00:08 +02:00
lebaudantoine cadb20793a 🔖(minor) bump release to 0.1.30
various fixes
2025-07-18 11:48:02 +02:00
lebaudantoine 8a417806e4 🐛(backend) fix lobby notification type error breaking participant alerts
Correct data type issue that was preventing lobby notifications from
being sent to other participants in the room.
2025-07-18 11:42:43 +02:00
lebaudantoine 223c744e3f 🚨(summary) lint celery_config module to pass ruff checks
Apply code formatting and style fixes to celery configuration
module to meet linting standards.
2025-07-18 11:29:13 +02:00
lebaudantoine f67335f306 🐛(summary) fix Celery task execution by number of required args
Forgot to update the args verification while enriching the task
with more recording metadata.
2025-07-18 11:29:13 +02:00
lebaudantoine 4eb7f29f8e 🔊(summary) add Celery exporter configuration for monitoring
Enable Celery task lifecycle events and broker dispatch events per
@rouja's exporter requirements. Basic configuration following
documentation without parameterization.
2025-07-17 23:53:09 +02:00
lebaudantoine 912bac8756 🔖(minor) bump release to 0.1.29
What:

- fix minor issue on summary microservice
- PKCE (oidc) support
- add limitation on recording if supported

and more.
2025-07-17 20:41:29 +02:00
lebaudantoine 3c97418a70 🐛(summary) fix Celery task execution by switching to apply_async()
Replace delay() with apply_async() to resolve invalid arguments error.
2025-07-17 20:41:29 +02:00
lebaudantoine 26a90456f7 🚸(frontend) add alert for media devices already in use
Show explicit warning when microphone/camera are occupied by other
applications. Helps users understand permission failures and reminds
them to close other video conferencing apps.

Spotted issue through Crisp support - users often forget to quit other
webconfs that don't auto-disconnect when alone.
2025-07-17 20:41:29 +02:00
lebaudantoine d068558c8f 🚸(frontend) explain duplicate identity disconnect on feedback page
Add context to feedback page when user is disconnected for joining with
same identity, which is forbidden by LiveKit. Improves user understanding
of disconnection reasons.
2025-07-17 20:41:29 +02:00
lebaudantoine dac6bfe142 ♻️(frontend) refactor feedback redirect to global onDisconnect hook
Centralize disconnect handling to ensure all client-initiated disconnects
trigger feedback page navigation. More extensible for future disconnect
event routing needs, especially when errors happen.
2025-07-17 17:41:04 +02:00
lebaudantoine 6b3e5d747a 📝(backend) add OIDC PKCE parameters to installation documentation
Document PKCE-related configuration options for OpenID Connect setup
in installation guide for proper authentication flow.
2025-07-16 15:17:39 +02:00
K900 3066e3a83c 🔒️(backend) add environment variables for PKCE settings
Defaulting to off for now to keep compatibility.
2025-07-16 14:52:44 +02:00
lebaudantoine 5e05b6b2a5 ⬆️(frontend) bump libphonenumber-js dependency
Update phone number formatting library to latest version for bug fixes
and improved international number handling.
2025-07-16 14:47:24 +02:00
lebaudantoine d075d60d19 🚸(frontend) notify all participants when recording/transcription stops
Broadcast limit-reached notifications to all room participants, not just
owner, to ensure everyone knows recording has stopped due to duration
limits.
2025-07-16 14:47:24 +02:00
lebaudantoine 7c631bb76f 🚸(frontend) alert room owner when recording exceeds max duration
Display notification to prevent silent recording failures. Shows
configured max duration in proper locale if backend provides the limit.
Prevents users from missing recording termination.
2025-07-16 14:47:24 +02:00
lebaudantoine 59cd1f766a (backend) add egress limit notification handler to LiveKit service
Implement method to process egress limit reached events from LiveKit
webhooks for better recording duration management.

Livekit by default is not notifying the participant of a room when
an egress reached its limit. I needed to proxy it through the back.
2025-07-16 14:47:24 +02:00
lebaudantoine f0a17b1ce1 (backend) add dedicated service for LiveKit recording webhook events
Create new service to handle recording-related webhooks, starting with
limit reached events. Will expand to enhance UX by notifying backend
of other LiveKit events.

Doesn't fit cleanly with existing recording package - may need broader
redesign. Chose dedicated service over mixing responsibilities.
2025-07-16 14:47:24 +02:00
lebaudantoine 17c486f7bf ♻️(backend) extract notify_participant to util function
Move from lobby service to utils for reuse across services. Method is
generic enough for utility status. Future: create dedicated LiveKit
service to encapsulate all LiveKit-related utilities.
2025-07-16 14:47:24 +02:00
lebaudantoine 85bde9633f 🔧(frontend) pass recording max duration to frontend for user alerts
Send backend recording duration limit to frontend to display warning
messages when recordings approach or reach maximum allowed length.

This configuration needs to be synced with the egres. I chose to keep
this duration in ms to be consistent with other settings.
2025-07-16 14:47:24 +02:00
Charles Hall 0eb715b0cd 📝(doc) document the oidc redirect uri 2025-07-14 16:58:50 +02:00
Charles Hall b0617dcfed 📝(doc) update project name in installation prose
References relating to k8s and things remain unchanged, because that's
probably going to be more involved to make sure such changes are
accurate.
2025-07-14 16:58:50 +02:00
lebaudantoine 6c4c44e933 (summary) enhance transcription document naming with room context
Add optional room name, recording time and date to generate better
document names based on user feedback. Template is customizable for
internationalization support.
2025-07-11 15:40:12 +02:00
lebaudantoine 16dde229cc 🚨(summary) lint analytics sources
Fix formatting issues.
2025-07-11 15:40:12 +02:00
lebaudantoine d01d6dd9d1 🚸(backend) clarify link sharing limitations in recording email
Add notice to email notifications that recording link sharing is not
supported in beta to prevent user confusion.
2025-07-11 14:14:02 +02:00
lebaudantoine 2a39978245 🚸(frontend) add beta warning about recording link sharing limitations
Display notice on recording page that link sharing is not available in
beta to prevent user confusion and set proper expectations.
2025-07-11 14:14:02 +02:00
lebaudantoine d85ed031a9 🚸(frontend) improve error messaging for recording access failures
Clarify that recording links are not shareable when users cannot load
recordings. Previous error messages were unclear about access restrictions.
2025-07-11 14:14:02 +02:00
lebaudantoine 5c8c81f97b 🐛(frontend) fix infinite loading for unauthenticated users on downloads
Reorganize exception handling in recording download screen to prevent
unauthenticated users from getting stuck in loading state. Caught by
production users - my bad;
2025-07-11 14:14:02 +02:00
lebaudantoine 3368a9b6af 🔧(summary) extract hardcoded title to configurable setting
Replace hardcoded title with configurable option to enable custom
branding for different deployments of the microservice.
2025-07-11 11:38:28 +02:00
lebaudantoine e87d0519ff 🔧(summary) make audio duration limit optional for deployment safety
Allow deploying without breaking changes while frontend implements proper
egress limit handling. Duration restriction can be enabled when ready.
2025-07-11 11:38:28 +02:00
lebaudantoine 731f0471aa ♻️(summary) rename TaskTracker to MetadataManager for clarity
Update class name to better reflect its responsibility for handling task
metadata rather than tracking tasks themselves.
2025-07-11 11:38:28 +02:00
lebaudantoine e458272745 🐛(summary) fix data type serialization issues with Redis and PostHog
Resolve float/int to string conversion problems when deserializing Redis
data for PostHog. Added type conversion fix - not bulletproof but works
for most cases. Avoid using for critical operations.
2025-07-11 11:38:28 +02:00
lebaudantoine d71e417d58 📝(summary) replace "wip" placeholder with proper docstring
Add proper function documentation to replace temporary placeholder
committed in previous hasty commit.
2025-07-11 11:38:28 +02:00
87 changed files with 1605 additions and 647 deletions
+126 -120
View File
@@ -1,6 +1,6 @@
# Installation on a k8s cluster # Installation on a k8s cluster
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features. This document is a step-by-step guide that describes how to install LaSuite Meet on a k8s cluster without AI features.
## Prerequisites for a kubernetes setup ## Prerequisites for a kubernetes setup
@@ -114,7 +114,7 @@ Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, excep
### What will you use to authenticate your users ? ### What will you use to authenticate your users ?
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo). LaSuite Meet uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus LaSuite Meet) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command: If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
@@ -134,6 +134,8 @@ keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s keycloak-postgresql-0 1/1 Running 0 6m48s
``` ```
In your OIDC provider, set LaSuite Meet's redirect URI to `https://.../api/v1.0/callback/` where `...` should be replaced with the domain name LaSuite Meet is hosted on.
From here the important information you will need are : From here the important information you will need are :
``` ```
@@ -152,7 +154,7 @@ You can find these values in **examples/keycloak.values.yaml**
### Find livekit server connexion values ### Find livekit server connexion values
Visio use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows: LaSuite Meet use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
Livekit need a redis (and meet too) so we will start by deploying a redis : Livekit need a redis (and meet too) so we will start by deploying a redis :
@@ -194,7 +196,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
### Find postgresql connexion values ### Find postgresql connexion values
Visio uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows: LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
``` ```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml $ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
@@ -222,7 +224,7 @@ POSTGRES_PASSWORD: pass
## Deployment ## Deployment
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart. Now you are ready to deploy LaSuite Meet without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
``` ```
$ helm repo add meet https://suitenumerique.github.io/meet/ $ helm repo add meet https://suitenumerique.github.io/meet/
@@ -243,123 +245,127 @@ 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 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 from the local device. The provisioning user in keycloak is meet/meet. You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
## All options ## All options
These are the environmental options available on meet backend. These are the environmental options available on meet backend.
| Option | Description | default | | Option | Description | default |
| ----------------------------------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| |-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data | | DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] | | DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | | | DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] | | DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false | | DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 | | DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet | | DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum | | DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass | | DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost | | DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 | | DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage | | STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | | | AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | | | AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | | | AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | | | AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage | | AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us | | DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 | | REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) | | SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute | | REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute | | CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false | | SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] | | CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | | | FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} | | FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} | | FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} | | FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} | | FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false | | FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true | | FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} | | FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false | | FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false | | FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend | | DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | Host of the email server | | | DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | | | DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | | | DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | | | DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false | | DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false | | DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com | | DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | | | EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | | | EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | | | EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | | | EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | | | EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false | | DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] | | DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] | | DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | | | SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 | | DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} | | DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true | | OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true | | OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false | | OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 | | OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet | | OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | | | OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | | | OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | | | OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | | | OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | | | OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO | | OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | | | OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} | | OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email | | OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true | | OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false | | OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] | | OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true | | OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo | | OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] | | OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name | | OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] | | OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| LOGIN_REDIRECT_URL | Login redirect URL | | | OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | | | OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | | | OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true | | LOGIN_REDIRECT_URL | Login redirect URL | |
| LIVEKIT_API_KEY | LiveKit API key | | | LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LIVEKIT_API_SECRET | LiveKit API secret | | | LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| LIVEKIT_API_URL | LiveKit API URL | | | ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true | | LIVEKIT_API_KEY | LiveKit API key | |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public | | LIVEKIT_API_SECRET | LiveKit API secret | |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true | | LIVEKIT_API_URL | LiveKit API URL | |
| RECORDING_ENABLE | Record meeting option | false | | LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings | | RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} | | ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser | | RECORDING_ENABLE | Record meeting option | false |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true | | RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false | | RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | | | RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | | | RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | | | RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | | | RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | | | RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false | | RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService | | SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| BREVO_API_KEY | Brevo API key for marketing emails | | | SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] | | SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} | | SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 | | MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby | | BREVO_API_KEY | Brevo API key for marketing emails | |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 | | BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 | | DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) | | BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting | | LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId | | LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) | | LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false | | LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 | | LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 | | LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
+1
View File
@@ -41,6 +41,7 @@ def get_frontend_configuration(request):
"is_enabled": settings.RECORDING_ENABLE, "is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(), "available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS, "expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
}, },
"telephony": { "telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED, "enabled": settings.ROOM_TELEPHONY_ENABLED,
@@ -136,6 +136,13 @@ class NotificationService:
"filename": recording.key, "filename": recording.key,
"email": owner_access.user.email, "email": owner_access.user.email,
"sub": owner_access.user.sub, "sub": owner_access.user.sub,
"room": recording.room.name,
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
} }
headers = { headers = {
@@ -0,0 +1,49 @@
"""Recording-related LiveKit Events Service"""
from logging import getLogger
from core import models, utils
logger = getLogger(__name__)
class RecordingEventsError(Exception):
"""Recording event handling fails."""
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@staticmethod
def handle_limit_reached(recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
recording.save()
notification_mapping = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
}
notification_type = notification_mapping.get(recording.mode)
if not notification_type:
return
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
logger.exception(
"Failed to notify participants about recording limit reached: "
"room=%s, recording_id=%s, mode=%s",
recording.room.id,
recording.id,
recording.mode,
)
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
@@ -1,5 +1,7 @@
"""LiveKit Events Service""" """LiveKit Events Service"""
# pylint: disable=E1101
import uuid import uuid
from enum import Enum from enum import Enum
from logging import getLogger from logging import getLogger
@@ -9,6 +11,10 @@ from django.conf import settings
from livekit import api from livekit import api
from core import models from core import models
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from .lobby import LobbyService from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService from .telephony import TelephonyException, TelephonyService
@@ -84,6 +90,7 @@ class LiveKitEventsService:
self.webhook_receiver = api.WebhookReceiver(token_verifier) self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService() self.lobby_service = LobbyService()
self.telephony_service = TelephonyService() self.telephony_service = TelephonyService()
self.recording_events = RecordingEventsService()
def receive(self, request): def receive(self, request):
"""Process webhook and route to appropriate handler.""" """Process webhook and route to appropriate handler."""
@@ -115,6 +122,29 @@ class LiveKitEventsService:
# pylint: disable=not-callable # pylint: disable=not-callable
handler(data) handler(data)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
try:
recording = models.Recording.objects.get(
worker_id=data.egress_info.egress_id
)
except models.Recording.DoesNotExist as err:
raise ActionFailedError(
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
try:
self.recording_events.handle_limit_reached(recording)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process limit reached event for recording {recording}"
) from e
def _handle_room_started(self, data): def _handle_room_started(self, data):
"""Handle 'room_started' event.""" """Handle 'room_started' event."""
+8 -56
View File
@@ -1,6 +1,5 @@
"""Lobby Service""" """Lobby Service"""
import json
import logging import logging
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
@@ -11,13 +10,6 @@ from uuid import UUID
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
SendDataRequest,
TwirpError,
)
from core import models, utils from core import models, utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,10 +38,6 @@ class LobbyParticipantNotFound(LobbyError):
"""Raised when participant is not found.""" """Raised when participant is not found."""
class LobbyNotificationError(LobbyError):
"""Raised when LiveKit notification fails."""
@dataclass @dataclass
class LobbyParticipant: class LobbyParticipant:
"""Participant in a lobby system.""" """Participant in a lobby system."""
@@ -211,9 +199,6 @@ class LobbyService:
Create a new participant entry in waiting status and notify room Create a new participant entry in waiting status and notify room
participants of the new entry request. participants of the new entry request.
Raises:
LobbyNotificationError: If room notification fails
""" """
color = utils.generate_color(participant_id) color = utils.generate_color(participant_id)
@@ -226,10 +211,15 @@ class LobbyService:
) )
try: try:
self.notify_participants(room_id=room_id) utils.notify_participants(
except LobbyNotificationError: room_name=str(room_id),
notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE,
},
)
except utils.NotificationError:
# If room not created yet, there is no participants to notify # If room not created yet, there is no participants to notify
pass logger.exception("Failed to notify room participants")
cache_key = self._get_cache_key(room_id, participant_id) cache_key = self._get_cache_key(room_id, participant_id)
cache.set( cache.set(
@@ -334,44 +324,6 @@ class LobbyService:
participant.status = status participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout) cache.set(cache_key, participant.to_dict(), timeout=timeout)
@async_to_sync
async def notify_participants(self, room_id: UUID):
"""Notify room participants about a new waiting participant using LiveKit.
Raises:
LobbyNotificationError: If notification fails to send
"""
notification_data = {
"type": settings.LOBBY_NOTIFICATION_TYPE,
}
lkapi = utils.create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[str(room_id)],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=str(room_id),
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
logger.exception("Failed to notify room participants")
raise LobbyNotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room.""" """Clear all participant entries from the cache for a specific room."""
@@ -0,0 +1,72 @@
"""
Test RecordingEventsService service.
"""
# pylint: disable=W0621
from unittest import mock
import pytest
from core.factories import RecordingFactory
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@pytest.fixture
def service():
"""Initialize RecordingEventsService."""
return RecordingEventsService()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached stops recording and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service.handle_limit_reached(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(status="active", mode=mode)
with pytest.raises(
RecordingEventsError,
match=r"Failed to notify participants in room '.+' "
r"about recording limit reached \(recording_id=.+\)",
):
service.handle_limit_reached(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@@ -37,7 +37,7 @@ def test_request_entry_anonymous(settings):
assert not lobby_keys assert not lobby_keys
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"), mock.patch.object(utils, "generate_color", return_value="mocked-color"),
): ):
response = client.post( response = client.post(
@@ -86,7 +86,7 @@ def test_request_entry_authenticated_user(settings):
assert not lobby_keys assert not lobby_keys
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"), mock.patch.object(utils, "generate_color", return_value="mocked-color"),
): ):
response = client.post( response = client.post(
@@ -156,7 +156,7 @@ def test_request_entry_with_existing_participants(settings):
# Mock external service calls to isolate the test # Mock external service calls to isolate the test
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"), mock.patch.object(utils, "generate_color", return_value="mocked-color"),
): ):
# Make request as a new anonymous user # Make request as a new anonymous user
@@ -205,7 +205,7 @@ def test_request_entry_public_room(settings):
assert not lobby_keys assert not lobby_keys
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123" LobbyService, "_get_or_create_participant_id", return_value="123"
), ),
@@ -255,7 +255,7 @@ def test_request_entry_authenticated_user_public_room(settings):
assert not lobby_keys assert not lobby_keys
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch.object(
LobbyService, LobbyService,
"_get_or_create_participant_id", "_get_or_create_participant_id",
@@ -315,7 +315,7 @@ def test_request_entry_waiting_participant_public_room(settings):
client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"}) client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"})
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"} utils, "generate_livekit_config", return_value={"token": "test-token"}
), ),
@@ -1,14 +1,16 @@
""" """
Test LiveKitEvents service. Test LiveKitEvents service.
""" """
# pylint: disable=W0621,W0613, W0212 # pylint: disable=W0621,W0613, W0212, E0611
import uuid import uuid
from unittest import mock from unittest import mock
import pytest import pytest
from livekit.api import EgressStatus
from core.factories import RoomFactory from core.factories import RecordingFactory, RoomFactory
from core.recording.services.recording_events import RecordingEventsService
from core.services.livekit_events import ( from core.services.livekit_events import (
ActionFailedError, ActionFailedError,
AuthenticationError, AuthenticationError,
@@ -19,6 +21,7 @@ from core.services.livekit_events import (
) )
from core.services.lobby import LobbyService from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService from core.services.telephony import TelephonyException, TelephonyService
from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -55,6 +58,107 @@ def test_initialization(
mock_token_verifier.assert_called_once_with(api_key, api_secret) mock_token_verifier.assert_called_once_with(api_key, api_secret)
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value) mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
assert isinstance(service.lobby_service, LobbyService) assert isinstance(service.lobby_service, LobbyService)
assert isinstance(service.telephony_service, TelephonyService)
assert isinstance(service.recording_events, RecordingEventsService)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
"""Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_notification_fails(mock_notify, service):
"""Should raise ActionFailedError when notification fails but still stop recording."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
mock_notify.side_effect = NotificationError("Error notifying")
with pytest.raises(
ActionFailedError,
match=r"Failed to process limit reached event for recording .+",
):
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_found(mock_notify, service):
"""Should raise ActionFailedError when recording doesn't exist."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-2"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
with pytest.raises(
ActionFailedError, match=r"Recording with worker ID .+ does not exist"
):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_active(mock_notify, service):
"""Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
recording.refresh_from_db()
assert recording.status == "failed_to_stop"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
"""Should ignore egress non-limit-reached statuses."""
recording = RecordingFactory(worker_id="worker-1", status="stopped")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
assert recording.status == "stopped"
@mock.patch.object(LobbyService, "clear_room_cache") @mock.patch.object(LobbyService, "clear_room_cache")
+7 -117
View File
@@ -5,7 +5,6 @@ Test lobby service.
# pylint: disable=W0621,W0613, W0212, R0913 # pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913 # ruff: noqa: PLR0913
import json
import uuid import uuid
from unittest import mock from unittest import mock
@@ -14,18 +13,17 @@ from django.core.cache import cache
from django.http import HttpResponse from django.http import HttpResponse
import pytest import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory from core.factories import RoomFactory
from core.models import RoomAccessLevel from core.models import RoomAccessLevel
from core.services.lobby import ( from core.services.lobby import (
LobbyNotificationError,
LobbyParticipant, LobbyParticipant,
LobbyParticipantNotFound, LobbyParticipantNotFound,
LobbyParticipantParsingError, LobbyParticipantParsingError,
LobbyParticipantStatus, LobbyParticipantStatus,
LobbyService, LobbyService,
) )
from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -414,7 +412,7 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
# pylint: disable=R0917 # pylint: disable=R0917
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color") @mock.patch("core.utils.generate_color")
@mock.patch("core.services.lobby.LobbyService.notify_participants") @mock.patch("core.utils.notify_participants")
def test_enter_success( def test_enter_success(
mock_notify, mock_notify,
mock_generate_color, mock_generate_color,
@@ -443,13 +441,15 @@ def test_enter_success(
participant.to_dict(), participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT, timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
mock_notify.assert_called_once_with(room_id=room.id) mock_notify.assert_called_once_with(
room_name=str(room.id), notification_data={"type": "participantWaiting"}
)
# pylint: disable=R0917 # pylint: disable=R0917
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color") @mock.patch("core.utils.generate_color")
@mock.patch("core.services.lobby.LobbyService.notify_participants") @mock.patch("core.utils.notify_participants")
def test_enter_with_notification_error( def test_enter_with_notification_error(
mock_notify, mock_notify,
mock_generate_color, mock_generate_color,
@@ -460,7 +460,7 @@ def test_enter_with_notification_error(
): ):
"""Test participant entry with notification error.""" """Test participant entry with notification error."""
mock_generate_color.return_value = "#123456" mock_generate_color.return_value = "#123456"
mock_notify.side_effect = LobbyNotificationError("Error notifying") mock_notify.side_effect = NotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -776,116 +776,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id) lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
"""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
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
"""Test successful participant notification."""
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()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == str(room.id)
assert (
json.loads(send_data_request.data.decode("utf-8"))["type"]
== settings.LOBBY_NOTIFICATION_TYPE
)
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
"""Test participant notification with API error."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(
LobbyNotificationError, match="Failed to notify room participants"
):
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
def test_clear_room_cache(settings, lobby_service): def test_clear_room_cache(settings, lobby_service):
"""Test clearing room cache actually removes entries from cache.""" """Test clearing room cache actually removes entries from cache."""
+107 -1
View File
@@ -2,9 +2,13 @@
Test utils functions Test utils functions
""" """
import json
from unittest import mock from unittest import mock
from core.utils import create_livekit_client import pytest
from livekit.api import TwirpError
from core.utils import NotificationError, create_livekit_client, notify_participants
@mock.patch("asyncio.get_running_loop") @mock.patch("asyncio.get_running_loop")
@@ -60,3 +64,105 @@ def test_create_livekit_client_custom_configuration(
create_livekit_client(custom_configuration) create_livekit_client(custom_configuration)
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None) mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client):
"""Test participant notification with API error."""
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(NotificationError, match="Failed to notify room participants"):
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client):
"""Test the notify_participants function when the LiveKit room doesn't exist."""
# 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()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client):
"""Test successful participant notification."""
# 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()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == "room-number-1"
assert json.loads(send_data_request.data.decode("utf-8")) == {"foo": "foo"}
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
+43 -1
View File
@@ -15,7 +15,15 @@ from django.core.files.storage import default_storage
import aiohttp import aiohttp
import botocore import botocore
from livekit.api import AccessToken, LiveKitAPI, VideoGrants from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
AccessToken,
ListRoomsRequest,
LiveKitAPI,
SendDataRequest,
TwirpError,
VideoGrants,
)
def generate_color(identity: str) -> str: def generate_color(identity: str) -> str:
@@ -158,3 +166,37 @@ def create_livekit_client(custom_configuration=None):
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
return LiveKitAPI(session=custom_session, **configuration) return LiveKitAPI(session=custom_session, **configuration)
class NotificationError(Exception):
"""Notification delivery to room participants fails."""
@async_to_sync
async def notify_participants(room_name: str, notification_data: dict):
"""Send notification data to all participants in a LiveKit room."""
lkapi = create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=room_name,
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
raise NotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
Binary file not shown.
+32 -23
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n" "POT-Creation-Date: 2025-07-11 11:33+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,15 +29,15 @@ msgstr "Berechtigungen"
msgid "Important dates" msgid "Important dates"
msgstr "Wichtige Daten" msgstr "Wichtige Daten"
#: core/admin.py:143 #: core/admin.py:147
msgid "No owner" msgid "No owner"
msgstr "Kein Eigentümer" msgstr "Kein Eigentümer"
#: core/admin.py:146 #: core/admin.py:150
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Mehrere Eigentümer" msgstr "Mehrere Eigentümer"
#: core/api/serializers.py:63 #: core/api/serializers.py:67
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe " "Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
@@ -251,7 +251,8 @@ msgstr "PIN-Code für den Raum"
#: core/models.py:392 #: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert." msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:398 core/models.py:552 #: core/models.py:398 core/models.py:552
msgid "Room" msgid "Room"
@@ -366,9 +367,9 @@ msgid "Make sure you have a stable internet connection"
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben" msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
#: core/templates/mail/html/invitation.html:248 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Vielen Dank für die Nutzung von %(brandname)s. " msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
@@ -394,33 +395,41 @@ msgstr ""
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. " msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:" msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:203 #: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"Open\" button below " msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Button „Öffnen“ unten " msgstr "Klicken Sie auf den Button „Öffnen“ unten "
#: core/templates/mail/html/screen_recording.html:204 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:13 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche " msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
#: core/templates/mail/html/screen_recording.html:205 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:14 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Speichern Sie die Datei an einem gewünschten Ort" msgstr "Speichern Sie die Datei an einem gewünschten Ort"
#: core/templates/mail/html/screen_recording.html:216 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Öffnen" msgstr "Öffnen"
#: core/templates/mail/html/screen_recording.html:225 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -429,18 +438,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte " " Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. " "an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:162 #: meet/settings.py:163
msgid "English" msgid "English"
msgstr "Englisch" msgstr "Englisch"
#: meet/settings.py:163 #: meet/settings.py:164
msgid "French" msgid "French"
msgstr "Französisch" msgstr "Französisch"
#: meet/settings.py:164 #: meet/settings.py:165
msgid "Dutch" msgid "Dutch"
msgstr "Niederländisch" msgstr "Niederländisch"
#: meet/settings.py:165 #: meet/settings.py:166
msgid "German" msgid "German"
msgstr "Deutsch" msgstr "Deutsch"
Binary file not shown.
+31 -22
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n" "POT-Creation-Date: 2025-07-11 11:33+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,15 +29,15 @@ msgstr "Permissions"
msgid "Important dates" msgid "Important dates"
msgstr "Important dates" msgstr "Important dates"
#: core/admin.py:143 #: core/admin.py:147
msgid "No owner" msgid "No owner"
msgstr "No owner" msgstr "No owner"
#: core/admin.py:146 #: core/admin.py:150
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Multiple owners" msgstr "Multiple owners"
#: core/api/serializers.py:63 #: core/api/serializers.py:67
msgid "You must be administrator or owner of a room to add accesses to it." 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." msgstr "You must be administrator or owner of a room to add accesses to it."
@@ -361,9 +361,9 @@ msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection" msgstr "Make sure you have a stable internet connection"
#: core/templates/mail/html/invitation.html:248 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. " msgstr " Thank you for using %(brandname)s. "
@@ -389,33 +389,42 @@ msgstr ""
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " The recording will expire in %(days)s days. " msgstr " The recording will expire in %(days)s days. "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" msgid "To keep this recording permanently:"
msgstr "To keep this recording permanently:" msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:203 #: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"Open\" button below " msgid "Click the \"Open\" button below "
msgstr "Click the \"Open\" button below " msgstr "Click the \"Open\" button below "
#: core/templates/mail/html/screen_recording.html:204 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:13 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface " msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:205 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:14 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Save the file to your preferred location" msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:216 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Open" msgstr "Open"
#: core/templates/mail/html/screen_recording.html:225 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -424,18 +433,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. " "team at %(support_email)s. "
#: meet/settings.py:162 #: meet/settings.py:163
msgid "English" msgid "English"
msgstr "English" msgstr "English"
#: meet/settings.py:163 #: meet/settings.py:164
msgid "French" msgid "French"
msgstr "French" msgstr "French"
#: meet/settings.py:164 #: meet/settings.py:165
msgid "Dutch" msgid "Dutch"
msgstr "Dutch" msgstr "Dutch"
#: meet/settings.py:165 #: meet/settings.py:166
msgid "German" msgid "German"
msgstr "German" msgstr "German"
Binary file not shown.
+36 -28
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n" "POT-Creation-Date: 2025-07-11 11:33+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n" "Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,15 +29,15 @@ msgstr "Permissions"
msgid "Important dates" msgid "Important dates"
msgstr "Dates importantes" msgstr "Dates importantes"
#: core/admin.py:143 #: core/admin.py:147
msgid "No owner" msgid "No owner"
msgstr "Pas de propriétaire" msgstr "Pas de propriétaire"
#: core/admin.py:146 #: core/admin.py:150
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Plusieurs propriétaires" msgstr "Plusieurs propriétaires"
#: core/api/serializers.py:63 #: core/api/serializers.py:67
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter " "Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
@@ -138,8 +138,8 @@ msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters." "_ characters."
msgstr "" msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, " "Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"des chiffres et les caractères @/./+/-/_." "chiffres et les caractères @/./+/-/_."
#: core/models.py:144 #: core/models.py:144
msgid "sub" msgid "sub"
@@ -252,7 +252,8 @@ msgstr "Code PIN de la salle"
#: core/models.py:392 #: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode." 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." msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:398 core/models.py:552 #: core/models.py:398 core/models.py:552
msgid "Room" msgid "Room"
@@ -271,9 +272,8 @@ msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
msgstr "" msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant " "Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"est conservé même lorsque le Worker s'arrête, permettant un suivi " "conservé même lorsque le Worker s'arrête, permettant un suivi facile."
"facile."
#: core/models.py:573 #: core/models.py:573
msgid "Recording mode" msgid "Recording mode"
@@ -367,9 +367,9 @@ msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable" msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: core/templates/mail/html/invitation.html:248 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. " msgstr " Merci d'utiliser %(brandname)s. "
@@ -395,33 +395,41 @@ msgstr ""
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " L'enregistrement expirera dans %(days)s jours. " msgstr " L'enregistrement expirera dans %(days)s jours. "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" msgid "To keep this recording permanently:"
msgstr "Pour conserver cet enregistrement de façon permanente :" msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:203 #: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"Open\" button below " msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous " msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:204 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:13 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface " msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
#: core/templates/mail/html/screen_recording.html:205 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:14 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Enregistrez le fichier à l'emplacement de votre choix" msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:216 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Ouvrir" msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:225 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -430,18 +438,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre " " Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. " "équipe d'assistance à %(support_email)s. "
#: meet/settings.py:162 #: meet/settings.py:163
msgid "English" msgid "English"
msgstr "Anglais" msgstr "Anglais"
#: meet/settings.py:163 #: meet/settings.py:164
msgid "French" msgid "French"
msgstr "Français" msgstr "Français"
#: meet/settings.py:164 #: meet/settings.py:165
msgid "Dutch" msgid "Dutch"
msgstr "Néerlandais" msgstr "Néerlandais"
#: meet/settings.py:165 #: meet/settings.py:166
msgid "German" msgid "German"
msgstr "Allemand" msgstr "Allemand"
Binary file not shown.
+32 -23
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-26 10:50+0000\n" "POT-Creation-Date: 2025-07-11 11:33+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,15 +29,15 @@ msgstr "Rechten"
msgid "Important dates" msgid "Important dates"
msgstr "Belangrijke datums" msgstr "Belangrijke datums"
#: core/admin.py:143 #: core/admin.py:147
msgid "No owner" msgid "No owner"
msgstr "Geen eigenaar" msgstr "Geen eigenaar"
#: core/admin.py:146 #: core/admin.py:150
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Meerdere eigenaren" msgstr "Meerdere eigenaren"
#: core/api/serializers.py:63 #: core/api/serializers.py:67
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen." "Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
@@ -247,7 +247,8 @@ msgstr "Pincode van de kamer"
#: core/models.py:392 #: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus." msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:398 core/models.py:552 #: core/models.py:398 core/models.py:552
msgid "Room" msgid "Room"
@@ -361,9 +362,9 @@ msgid "Make sure you have a stable internet connection"
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt" msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
#: core/templates/mail/html/invitation.html:248 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:240 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:22 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. " msgstr " Bedankt voor het gebruik van %(brandname)s. "
@@ -389,33 +390,41 @@ msgstr ""
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " De opname verloopt over %(days)s dagen. " msgstr " De opname verloopt over %(days)s dagen. "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:" msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:203 #: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"Open\" button below " msgid "Click the \"Open\" button below "
msgstr "Klik op de \"Openen\"-knop hieronder " msgstr "Klik op de \"Openen\"-knop hieronder "
#: core/templates/mail/html/screen_recording.html:204 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:13 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface " msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:205 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:14 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie" msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:216 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Openen" msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:225 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -424,18 +433,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support " " Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. " "team via %(support_email)s. "
#: meet/settings.py:162 #: meet/settings.py:163
msgid "English" msgid "English"
msgstr "Engels" msgstr "Engels"
#: meet/settings.py:163 #: meet/settings.py:164
msgid "French" msgid "French"
msgstr "Frans" msgstr "Frans"
#: meet/settings.py:164 #: meet/settings.py:165
msgid "Dutch" msgid "Dutch"
msgstr "Nederlands" msgstr "Nederlands"
#: meet/settings.py:165 #: meet/settings.py:166
msgid "German" msgid "German"
msgstr "Duits" msgstr "Duits"
+16
View File
@@ -430,6 +430,17 @@ class Base(Configuration):
OIDC_RP_SCOPES = values.Value( OIDC_RP_SCOPES = values.Value(
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None "openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
) )
OIDC_USE_PKCE = values.BooleanValue(
default=False, environ_name="OIDC_USE_PKCE", environ_prefix=None
)
OIDC_PKCE_CODE_CHALLENGE_METHOD = values.Value(
default="S256",
environ_name="OIDC_PKCE_CODE_CHALLENGE_METHOD",
environ_prefix=None,
)
OIDC_PKCE_CODE_VERIFIER_SIZE = values.IntegerValue(
default=64, environ_name="OIDC_PKCE_CODE_VERIFIER_SIZE", environ_prefix=None
)
LOGIN_REDIRECT_URL = values.Value( LOGIN_REDIRECT_URL = values.Value(
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
) )
@@ -525,6 +536,11 @@ class Base(Configuration):
RECORDING_EXPIRATION_DAYS = values.IntegerValue( RECORDING_EXPIRATION_DAYS = values.IntegerValue(
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
) )
# Recording max duration in milliseconds - must be synced with LiveKit Egress configuration
# Set to None for no max duration
RECORDING_MAX_DURATION = values.IntegerValue(
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value( SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
) )
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "meet" name = "meet"
version = "0.1.28" version = "0.1.30"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
+73 -6
View File
@@ -1,28 +1,30 @@
{ {
"name": "meet", "name": "meet",
"version": "0.1.28", "version": "0.1.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "0.1.28", "version": "0.1.30",
"dependencies": { "dependencies": {
"@livekit/components-react": "2.9.13", "@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6", "@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.5.7", "@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0", "@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5", "@react-aria/toast": "3.0.5",
"@react-hook/size": "2.1.2",
"@remixicon/react": "4.6.0", "@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5", "@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25", "crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1", "i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0", "i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0", "i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.9", "libphonenumber-js": "1.12.10",
"livekit-client": "2.15.2", "livekit-client": "2.15.2",
"posthog-js": "1.256.2", "posthog-js": "1.256.2",
"react": "18.3.1", "react": "18.3.1",
@@ -37,6 +39,7 @@
"@pandacss/dev": "0.54.0", "@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2", "@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5", "@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0", "@types/node": "22.16.0",
"@types/react": "18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
@@ -1208,6 +1211,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@juggle/resize-observer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
"license": "Apache-2.0"
},
"node_modules/@livekit/components-core": { "node_modules/@livekit/components-core": {
"version": "0.12.8", "version": "0.12.8",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.8.tgz", "resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.8.tgz",
@@ -2699,6 +2708,51 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
} }
}, },
"node_modules/@react-hook/latest": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@react-hook/latest/-/latest-1.0.3.tgz",
"integrity": "sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/@react-hook/passive-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz",
"integrity": "sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/@react-hook/resize-observer": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@react-hook/resize-observer/-/resize-observer-1.2.6.tgz",
"integrity": "sha512-DlBXtLSW0DqYYTW3Ft1/GQFZlTdKY5VAFIC4+km6IK5NiPPDFchGbEJm1j6pSgMqPRHbUQgHJX7RaR76ic1LWA==",
"license": "MIT",
"dependencies": {
"@juggle/resize-observer": "^3.3.1",
"@react-hook/latest": "^1.0.2",
"@react-hook/passive-layout-effect": "^1.2.0"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/@react-hook/size": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@react-hook/size/-/size-2.1.2.tgz",
"integrity": "sha512-BmE5asyRDxSuQ9p14FUKJ0iBRgV9cROjqNG9jT/EjCM+xHha1HVqbPoT+14FQg1K7xIydabClCibUY4+1tw/iw==",
"license": "MIT",
"dependencies": {
"@react-hook/passive-layout-effect": "^1.2.0",
"@react-hook/resize-observer": "^1.2.1"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/@react-stately/autocomplete": { "node_modules/@react-stately/autocomplete": {
"version": "3.0.0-beta.2", "version": "3.0.0-beta.2",
"resolved": "https://registry.npmjs.org/@react-stately/autocomplete/-/autocomplete-3.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/@react-stately/autocomplete/-/autocomplete-3.0.0-beta.2.tgz",
@@ -3994,6 +4048,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/humanize-duration": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.4.tgz",
"integrity": "sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/minimatch": { "node_modules/@types/minimatch": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
@@ -6535,6 +6596,12 @@
"entities": "^4.5.0" "entities": "^4.5.0"
} }
}, },
"node_modules/humanize-duration": {
"version": "3.33.0",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.0.tgz",
"integrity": "sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==",
"license": "Unlicense"
},
"node_modules/i18next": { "node_modules/i18next": {
"version": "25.3.1", "version": "25.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz",
@@ -7209,9 +7276,9 @@
} }
}, },
"node_modules/libphonenumber-js": { "node_modules/libphonenumber-js": {
"version": "1.12.9", "version": "1.12.10",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.9.tgz", "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.10.tgz",
"integrity": "sha512-VWwAdNeJgN7jFOD+wN4qx83DTPMVPPAUyx9/TUkBXKLiNkuWWk6anV0439tgdtwaJDrEdqkvdN22iA6J4bUCZg==", "integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lightningcss": { "node_modules/lightningcss": {
+5 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "0.1.28", "version": "0.1.30",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -18,18 +18,20 @@
"@livekit/track-processors": "0.5.7", "@livekit/track-processors": "0.5.7",
"@pandacss/preset-panda": "0.54.0", "@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5", "@react-aria/toast": "3.0.5",
"@react-hook/size": "2.1.2",
"@remixicon/react": "4.6.0", "@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5", "@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.0.25", "crisp-sdk-web": "1.0.25",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1", "i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0", "i18next-browser-languagedetector": "8.2.0",
"i18next-parser": "9.3.0", "i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.2", "livekit-client": "2.15.2",
"posthog-js": "1.256.2", "posthog-js": "1.256.2",
"libphonenumber-js": "1.12.9",
"react": "18.3.1", "react": "18.3.1",
"react-aria-components": "1.10.1", "react-aria-components": "1.10.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
@@ -42,6 +44,7 @@
"@pandacss/dev": "0.54.0", "@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2", "@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5", "@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0", "@types/node": "22.16.0",
"@types/react": "18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
+1
View File
@@ -29,6 +29,7 @@ export interface ApiConfig {
is_enabled?: boolean is_enabled?: boolean
available_modes?: RecordingMode[] available_modes?: RecordingMode[]
expiration_days?: number expiration_days?: number
max_duration?: number
} }
telephony: { telephony: {
enabled: boolean enabled: boolean
@@ -72,29 +72,33 @@ export const MainNotificationToast = () => {
) => { ) => {
const notification = decodeNotificationDataReceived(payload) const notification = decodeNotificationDataReceived(payload)
if (!participant || !notification) return if (!notification) return
switch (notification.type) { switch (notification.type) {
case NotificationType.ParticipantMuted: case NotificationType.ParticipantMuted:
toastQueue.add( if (participant) {
{ toastQueue.add(
participant, {
type: NotificationType.ParticipantMuted, participant,
}, type: NotificationType.ParticipantMuted,
{ timeout: NotificationDuration.ALERT } },
) { timeout: NotificationDuration.ALERT }
)
}
break break
case NotificationType.ReactionReceived: case NotificationType.ReactionReceived:
if (notification.data?.emoji) if (notification.data?.emoji && participant)
handleEmoji(notification.data.emoji, participant) handleEmoji(notification.data.emoji, participant)
break break
case NotificationType.TranscriptionStarted: case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped: case NotificationType.TranscriptionStopped:
case NotificationType.ScreenRecordingStarted: case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped: case NotificationType.ScreenRecordingStopped:
case NotificationType.TranscriptionLimitReached:
case NotificationType.ScreenRecordingLimitReached:
toastQueue.add( toastQueue.add(
{ {
participant,
type: notification.type, type: notification.type,
}, },
{ timeout: NotificationDuration.ALERT } { timeout: NotificationDuration.ALERT }
@@ -8,7 +8,9 @@ export enum NotificationType {
ParticipantWaiting = 'participantWaiting', ParticipantWaiting = 'participantWaiting',
TranscriptionStarted = 'transcriptionStarted', TranscriptionStarted = 'transcriptionStarted',
TranscriptionStopped = 'transcriptionStopped', TranscriptionStopped = 'transcriptionStopped',
TranscriptionLimitReached = 'transcriptionLimitReached',
ScreenRecordingStarted = 'screenRecordingStarted', ScreenRecordingStarted = 'screenRecordingStarted',
ScreenRecordingStopped = 'screenRecordingStopped', ScreenRecordingStopped = 'screenRecordingStopped',
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
RecordingSaving = 'recordingSaving', RecordingSaving = 'recordingSaving',
} }
@@ -19,10 +19,14 @@ export function ToastAnyRecording({ state, ...props }: ToastProps) {
return 'transcript.started' return 'transcript.started'
case NotificationType.TranscriptionStopped: case NotificationType.TranscriptionStopped:
return 'transcript.stopped' return 'transcript.stopped'
case NotificationType.TranscriptionLimitReached:
return 'transcript.limitReached'
case NotificationType.ScreenRecordingStarted: case NotificationType.ScreenRecordingStarted:
return 'screenRecording.started' return 'screenRecording.started'
case NotificationType.ScreenRecordingStopped: case NotificationType.ScreenRecordingStopped:
return 'screenRecording.stopped' return 'screenRecording.stopped'
case NotificationType.ScreenRecordingLimitReached:
return 'screenRecording.limitReached'
default: default:
return return
} }
@@ -40,7 +44,7 @@ export function ToastAnyRecording({ state, ...props }: ToastProps) {
gap={0} gap={0}
> >
{t(key, { {t(key, {
name: participant.name, name: participant?.name,
})} })}
</HStack> </HStack>
</StyledToastContainer> </StyledToastContainer>
@@ -29,6 +29,9 @@ export function ToastJoined({ state, ...props }: ToastProps) {
) )
const layoutContext = useMaybeLayoutContext() const layoutContext = useMaybeLayoutContext()
const participant = props.toast.content.participant const participant = props.toast.content.participant
if (!participant) return
const trackReference = { const trackReference = {
participant, participant,
publication: participant.getTrackPublication(Source.Camera), publication: participant.getTrackPublication(Source.Camera),
@@ -27,7 +27,7 @@ export function ToastMessageReceived({ state, ...props }: ToastProps) {
} }
}, [isChatOpen, toast, state]) }, [isChatOpen, toast, state])
if (isChatOpen) return null if (isChatOpen || !participant) return null
return ( return (
<StyledToastContainer {...toastProps} ref={ref}> <StyledToastContainer {...toastProps} ref={ref}>
@@ -10,6 +10,9 @@ export function ToastMuted({ state, ...props }: ToastProps) {
const ref = useRef(null) const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref) const { toastProps, contentProps } = useToast(props, state, ref)
const participant = props.toast.content.participant const participant = props.toast.content.participant
if (!participant) return
return ( return (
<StyledToastContainer {...toastProps} ref={ref}> <StyledToastContainer {...toastProps} ref={ref}>
<HStack <HStack
@@ -5,7 +5,7 @@ import { Participant } from 'livekit-client'
import { NotificationType } from '../NotificationType' import { NotificationType } from '../NotificationType'
export interface ToastData { export interface ToastData {
participant: Participant participant?: Participant
type: NotificationType type: NotificationType
message?: string message?: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -20,6 +20,8 @@ export function ToastRaised({ state, ...props }: ToastProps) {
const participant = props.toast.content.participant const participant = props.toast.content.participant
const { isParticipantsOpen, toggleParticipants } = useSidePanel() const { isParticipantsOpen, toggleParticipants } = useSidePanel()
if (!participant) return
return ( return (
<StyledToastContainer {...toastProps} ref={ref}> <StyledToastContainer {...toastProps} ref={ref}>
<HStack <HStack
@@ -40,8 +40,10 @@ const renderToast = (
case NotificationType.TranscriptionStarted: case NotificationType.TranscriptionStarted:
case NotificationType.TranscriptionStopped: case NotificationType.TranscriptionStopped:
case NotificationType.TranscriptionLimitReached:
case NotificationType.ScreenRecordingStarted: case NotificationType.ScreenRecordingStarted:
case NotificationType.ScreenRecordingStopped: case NotificationType.ScreenRecordingStopped:
case NotificationType.ScreenRecordingLimitReached:
return <ToastAnyRecording key={toast.key} toast={toast} state={state} /> return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
case NotificationType.RecordingSaving: case NotificationType.RecordingSaving:
@@ -0,0 +1,38 @@
import { useTranslation } from 'react-i18next'
import { Button, Dialog, P } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { useConfig } from '@/api/useConfig'
import humanizeDuration from 'humanize-duration'
export const LimitReachedAlertDialog = ({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) => {
const { t, i18n } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast.limitReachedAlert',
})
const { data } = useConfig()
return (
<Dialog isOpen={isOpen} role="alertdialog" title={t('title')}>
<P>
{t('description', {
duration_message: data?.recording?.max_duration
? t('durationMessage', {
duration: humanizeDuration(data?.recording?.max_duration, {
language: i18n.language,
}),
})
: '',
})}
</P>
<HStack gap={1}>
<Button variant="text" size="sm" onPress={onClose}>
{t('button')}
</Button>
</HStack>
</Dialog>
)
}
@@ -1,11 +1,11 @@
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio/index' import { useSnapshot } from 'valtio'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { Spinner } from '@/primitives/Spinner' import { Spinner } from '@/primitives/Spinner'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Text } from '@/primitives' import { Text } from '@/primitives'
import { RemoteParticipant, RoomEvent } from 'livekit-client' import { RoomEvent } from 'livekit-client'
import { decodeNotificationDataReceived } from '@/features/notifications/utils' import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType' import { NotificationType } from '@/features/notifications/NotificationType'
import { RecordingStatus, recordingStore } from '@/stores/recording' import { RecordingStatus, recordingStore } from '@/stores/recording'
@@ -18,14 +18,18 @@ import {
import { FeatureFlags } from '@/features/analytics/enums' import { FeatureFlags } from '@/features/analytics/enums'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
export const RecordingStateToast = () => { export const RecordingStateToast = () => {
const { t } = useTranslation('rooms', { const { t } = useTranslation('rooms', {
keyPrefix: 'recordingStateToast', keyPrefix: 'recordingStateToast',
}) })
const room = useRoomContext() const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner()
const { openTranscript, openScreenRecording } = useSidePanel() const { openTranscript, openScreenRecording } = useSidePanel()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const recordingSnap = useSnapshot(recordingStore) const recordingSnap = useSnapshot(recordingStore)
@@ -53,13 +57,10 @@ export const RecordingStateToast = () => {
}, [room.isRecording]) }, [room.isRecording])
useEffect(() => { useEffect(() => {
const handleDataReceived = ( const handleDataReceived = (payload: Uint8Array) => {
payload: Uint8Array,
participant?: RemoteParticipant
) => {
const notification = decodeNotificationDataReceived(payload) const notification = decodeNotificationDataReceived(payload)
if (!participant || !notification) return if (!notification) return
switch (notification.type) { switch (notification.type) {
case NotificationType.TranscriptionStarted: case NotificationType.TranscriptionStarted:
@@ -68,12 +69,20 @@ export const RecordingStateToast = () => {
case NotificationType.TranscriptionStopped: case NotificationType.TranscriptionStopped:
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break break
case NotificationType.TranscriptionLimitReached:
if (isAdminOrOwner) setIsAlertOpen(true)
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
break
case NotificationType.ScreenRecordingStarted: case NotificationType.ScreenRecordingStarted:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
break break
case NotificationType.ScreenRecordingStopped: case NotificationType.ScreenRecordingStopped:
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break break
case NotificationType.ScreenRecordingLimitReached:
if (isAdminOrOwner) setIsAlertOpen(true)
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
break
default: default:
return return
} }
@@ -100,7 +109,7 @@ export const RecordingStateToast = () => {
room.off(RoomEvent.DataReceived, handleDataReceived) room.off(RoomEvent.DataReceived, handleDataReceived)
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged) room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
} }
}, [room, recordingSnap]) }, [room, recordingSnap, setIsAlertOpen, isAdminOrOwner])
const key = useMemo(() => { const key = useMemo(() => {
switch (recordingSnap.status) { switch (recordingSnap.status) {
@@ -119,7 +128,14 @@ export const RecordingStateToast = () => {
} }
}, [recordingSnap]) }, [recordingSnap])
if (!key) return if (!key)
return isAdminOrOwner ? (
<LimitReachedAlertDialog
isOpen={isAlertOpen}
onClose={() => setIsAlertOpen(false)}
aria-label="Recording limit exceeded"
/>
) : null
const isStarted = key?.includes('started') const isStarted = key?.includes('started')
@@ -14,6 +14,28 @@ import { fetchRecording } from '../api/fetchRecording'
import { RecordingStatus } from '@/features/recording' import { RecordingStatus } from '@/features/recording'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
const BetaBadge = () => (
<span
className={css({
content: '"Beta"',
display: 'block',
letterSpacing: '-0.02rem',
padding: '0 0.25rem',
backgroundColor: 'primary.100',
color: '#0063CB',
fontSize: '14px',
fontWeight: 500,
margin: '0 0.3125rem',
lineHeight: '1rem',
borderRadius: '4px',
width: 'fit-content',
height: 'fit-content',
})}
>
Beta
</span>
)
export const RecordingDownload = () => { export const RecordingDownload = () => {
const { t } = useTranslation('recording') const { t } = useTranslation('recording')
const { data: configData } = useConfig() const { data: configData } = useConfig()
@@ -27,11 +49,11 @@ export const RecordingDownload = () => {
enabled: !!recordingId, enabled: !!recordingId,
}) })
if (isLoading || !data || isAuthLoading) { if (isLoggedIn === undefined || isAuthLoading) {
return <LoadingScreen /> return <LoadingScreen />
} }
if (!isLoggedIn) { if (isLoggedIn === false && !isAuthLoading) {
return ( return (
<ErrorScreen <ErrorScreen
title={t('authentication.title')} title={t('authentication.title')}
@@ -40,7 +62,11 @@ export const RecordingDownload = () => {
) )
} }
if (isError) { if (isLoading) {
return <LoadingScreen />
}
if (isError || !data) {
return <ErrorScreen title={t('error.title')} body={t('error.body')} /> return <ErrorScreen title={t('error.title')} body={t('error.body')} />
} }
@@ -103,6 +129,28 @@ export const RecordingDownload = () => {
> >
{t('success.button')} {t('success.button')}
</LinkButton> </LinkButton>
<div
className={css({
backgroundColor: 'greyscale.50',
borderRadius: '5px',
paddingY: '1rem',
paddingX: '1rem',
maxWidth: '80%',
marginTop: '1rem',
display: 'flex',
flexDirection: 'column',
})}
>
<Text
className={css({
display: 'flex',
alignItems: 'center',
})}
>
{t('success.warning.title')} <BetaBadge />
</Text>
<Text variant="smNote">{t('success.warning.body')}</Text>
</div>
</VStack> </VStack>
</Center> </Center>
</Screen> </Screen>
@@ -2,7 +2,14 @@ import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LiveKitRoom } from '@livekit/components-react' import { LiveKitRoom } from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client' import {
DisconnectReason,
MediaDeviceFailure,
Room,
RoomOptions,
supportsAdaptiveStream,
supportsDynacast,
} from 'livekit-client'
import { keys } from '@/api/queryKeys' import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
@@ -17,6 +24,8 @@ import posthog from 'posthog-js'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices' import { LocalUserChoices } from '@/stores/userChoices'
import { navigateTo } from '@/navigation/navigateTo'
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -67,8 +76,8 @@ export const Conference = ({
const roomOptions = useMemo((): RoomOptions => { const roomOptions = useMemo((): RoomOptions => {
return { return {
adaptiveStream: true, adaptiveStream: supportsAdaptiveStream(),
dynacast: true, dynacast: supportsDynacast(),
publishDefaults: { publishDefaults: {
videoCodec: 'vp9', videoCodec: 'vp9',
}, },
@@ -85,6 +94,13 @@ export const Conference = ({
const room = useMemo(() => new Room(roomOptions), [roomOptions]) const room = useMemo(() => new Room(roomOptions), [roomOptions])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create') const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
const [mediaDeviceError, setMediaDeviceError] = useState<{
error: MediaDeviceFailure | null
kind: MediaDeviceKind | null
}>({
error: null,
kind: null,
})
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
if (isCreateError) { if (isCreateError) {
@@ -124,6 +140,18 @@ export const Conference = ({
className={css({ className={css({
backgroundColor: 'primaryDark.50 !important', backgroundColor: 'primaryDark.50 !important',
})} })}
onDisconnected={(e) => {
if (e == DisconnectReason.CLIENT_INITIATED) {
navigateTo('feedback', { duplicateIdentity: false })
} else if (e == DisconnectReason.DUPLICATE_IDENTITY) {
navigateTo('feedback', { duplicateIdentity: true })
}
}}
onMediaDeviceFailure={(e, kind) => {
if (e == MediaDeviceFailure.DeviceInUse && !!kind) {
setMediaDeviceError({ error: e, kind })
}
}}
> >
<VideoConference /> <VideoConference />
{showInviteDialog && ( {showInviteDialog && (
@@ -134,6 +162,10 @@ export const Conference = ({
onClose={() => setShowInviteDialog(false)} onClose={() => setShowInviteDialog(false)}
/> />
)} )}
<MediaDeviceErrorAlert
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
/>
</LiveKitRoom> </LiveKitRoom>
</Screen> </Screen>
</QueryAware> </QueryAware>
@@ -0,0 +1,33 @@
import { MediaDeviceFailure } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Button, Dialog, P } from '@/primitives'
export type MediaDeviceErrorAlertProps = {
error?: MediaDeviceFailure | null
kind?: MediaDeviceKind | null
onClose: () => void
}
export const MediaDeviceErrorAlert = ({
error,
kind,
onClose,
}: MediaDeviceErrorAlertProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'mediaErrorDialog' })
if (!error || !kind) return
return (
<Dialog
role="alertdialog"
isOpen={!!error}
onClose={onClose}
title={t(`${error}.title.${kind}`)}
>
<P>{t(`${error}.body.${kind}`)}</P>
<Button variant="text" size="sm" onPress={onClose}>
{t('close')}
</Button>
</Dialog>
)
}
@@ -4,9 +4,9 @@ import { useMemo, useRef } from 'react'
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences' import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { useLocalParticipant } from '@livekit/components-react' import { useLocalParticipant } from '@livekit/components-react'
import { useSize } from '../hooks/useResizeObserver'
import { TrackReferenceOrPlaceholder } from '@livekit/components-core' import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSize from '@react-hook/size'
export const FullScreenShareWarning = ({ export const FullScreenShareWarning = ({
trackReference, trackReference,
@@ -16,7 +16,7 @@ export const FullScreenShareWarning = ({
const { t } = useTranslation('rooms', { keyPrefix: 'fullScreenWarning' }) const { t } = useTranslation('rooms', { keyPrefix: 'fullScreenWarning' })
const warningContainerRef = useRef<HTMLDivElement>(null) const warningContainerRef = useRef<HTMLDivElement>(null)
const { width: containerWidth } = useSize(warningContainerRef) const containerWidth = useSize(warningContainerRef)[0]
const { localParticipant } = useLocalParticipant() const { localParticipant } = useLocalParticipant()
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore) const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
@@ -3,8 +3,8 @@ import { styled } from '@/styled-system/jsx'
import { Avatar } from '@/components/Avatar' import { Avatar } from '@/components/Avatar'
import { useIsSpeaking } from '@livekit/components-react' import { useIsSpeaking } from '@livekit/components-react'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor' import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import useSize from '@react-hook/size'
const StyledParticipantPlaceHolder = styled('div', { const StyledParticipantPlaceHolder = styled('div', {
base: { base: {
@@ -28,7 +28,7 @@ export const ParticipantPlaceholder = ({
const participantColor = getParticipantColor(participant) const participantColor = getParticipantColor(participant)
const placeholderEl = useRef<HTMLDivElement>(null) const placeholderEl = useRef<HTMLDivElement>(null)
const { width, height } = useSize(placeholderEl) const [width, height] = useSize(placeholderEl)
const minDimension = Math.min(width, height) const minDimension = Math.min(width, height)
const avatarSize = useMemo( const avatarSize = useMemo(
@@ -1,6 +1,5 @@
import { useConnectionState, useRoomContext } from '@livekit/components-react' import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { navigateTo } from '@/navigation/navigateTo'
import { RiPhoneFill } from '@remixicon/react' import { RiPhoneFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ConnectionState } from 'livekit-client' import { ConnectionState } from 'livekit-client'
@@ -21,9 +20,6 @@ export const LeaveButton = () => {
.catch((e) => .catch((e) =>
console.error('An error occurred while disconnecting:', e) console.error('An error occurred while disconnecting:', e)
) )
.finally(() => {
navigateTo('feedback')
})
}} }}
data-attr="controls-leave" data-attr="controls-leave"
> >
@@ -0,0 +1,55 @@
import * as React from 'react'
import { createInteractingObservable } from '@livekit/components-core'
import { usePagination } from '@livekit/components-react'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
export interface PaginationControlProps
extends Pick<
ReturnType<typeof usePagination>,
'totalPageCount' | 'nextPage' | 'prevPage' | 'currentPage'
> {
/** Reference to an HTML element that holds the pages, while interacting (`mouseover`)
* with it, the pagination controls will appear for a while. */
pagesContainer?: React.RefObject<HTMLElement>
}
export function PaginationControl({
totalPageCount,
nextPage,
prevPage,
currentPage,
pagesContainer: connectedElement,
}: PaginationControlProps) {
const [interactive, setInteractive] = React.useState(false)
React.useEffect(() => {
let subscription:
| ReturnType<ReturnType<typeof createInteractingObservable>['subscribe']>
| undefined
if (connectedElement) {
subscription = createInteractingObservable(
connectedElement.current,
2000
).subscribe(setInteractive)
}
return () => {
if (subscription) {
subscription.unsubscribe()
}
}
}, [connectedElement])
return (
<div
className="lk-pagination-control"
data-lk-user-interaction={interactive}
>
<button className="lk-button" onClick={prevPage}>
<RiArrowLeftSLine />
</button>
<span className="lk-pagination-count">{`${currentPage} of ${totalPageCount}`}</span>
<button className="lk-button" onClick={nextPage}>
<RiArrowRightSLine />
</button>
</div>
)
}
@@ -0,0 +1,30 @@
import * as React from 'react'
export interface PaginationIndicatorProps {
totalPageCount: number
currentPage: number
}
export const PaginationIndicator: (
props: PaginationIndicatorProps & React.RefAttributes<HTMLDivElement>
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
HTMLDivElement,
PaginationIndicatorProps
>(function PaginationIndicator(
{ totalPageCount, currentPage }: PaginationIndicatorProps,
ref
) {
const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
if (index + 1 === currentPage) {
return <span data-lk-active key={index} />
} else {
return <span key={index} />
}
})
return (
<div ref={ref} className="lk-pagination-indicator">
{bubbles}
</div>
)
})
@@ -0,0 +1,92 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { getScrollBarWidth } from '@livekit/components-core'
import * as React from 'react'
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
import useSize from '@react-hook/size'
const MIN_HEIGHT = 130
const MIN_WIDTH = 140
const MIN_VISIBLE_TILES = 1
const ASPECT_RATIO = 16 / 10
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
/** @public */
export interface CarouselLayoutProps
extends React.HTMLAttributes<HTMLMediaElement> {
tracks: TrackReferenceOrPlaceholder[]
children: React.ReactNode
/** Place the tiles vertically or horizontally next to each other.
* If undefined orientation is guessed by the dimensions of the container. */
orientation?: 'vertical' | 'horizontal'
}
/**
* The `CarouselLayout` component displays a list of tracks in a scroll container.
* It will display as many tiles as possible and overflow the rest.
* @remarks
* To ensure visual stability when tiles are reordered due to track updates,
* the component uses the `useVisualStableUpdate` hook.
* @example
* ```tsx
* const tracks = useTracks([Track.Source.Camera]);
* <CarouselLayout tracks={tracks}>
* <ParticipantTile />
* </CarouselLayout>
* ```
* @public
*/
export function CarouselLayout({
tracks,
orientation,
...props
}: CarouselLayoutProps) {
const asideEl = React.useRef<HTMLDivElement>(null)
const [prevTiles, setPrevTiles] = React.useState(0)
const [width, height] = useSize(asideEl)
const carouselOrientation = orientation
? orientation
: height >= width
? 'vertical'
: 'horizontal'
const tileSpan =
carouselOrientation === 'vertical'
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
const scrollBarWidth = getScrollBarWidth()
const tilesThatFit =
carouselOrientation === 'vertical'
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
let maxVisibleTiles = Math.round(tilesThatFit)
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
maxVisibleTiles = Math.round(prevTiles)
} else if (prevTiles !== tilesThatFit) {
setPrevTiles(tilesThatFit)
}
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
React.useLayoutEffect(() => {
if (asideEl.current) {
asideEl.current.dataset.lkOrientation = carouselOrientation
asideEl.current.style.setProperty(
'--lk-max-visible-tiles',
maxVisibleTiles.toString()
)
}
}, [maxVisibleTiles, carouselOrientation])
return (
<aside
key={carouselOrientation}
className="lk-carousel"
ref={asideEl}
{...props}
>
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
</aside>
)
}
@@ -0,0 +1,71 @@
import * as React from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import {
TrackLoop,
usePagination,
UseParticipantsOptions,
useSwipe,
} from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps'
import { PaginationIndicator } from '../controls/PaginationIndicator'
import { useGridLayout } from '../../hooks/useGridLayout'
import { PaginationControl } from '../controls/PaginationControl'
/** @public */
export interface GridLayoutProps
extends React.HTMLAttributes<HTMLDivElement>,
Pick<UseParticipantsOptions, 'updateOnlyOn'> {
children: React.ReactNode
tracks: TrackReferenceOrPlaceholder[]
}
/**
* The `GridLayout` component displays the nested participants in a grid where every participants has the same size.
* It also supports pagination if there are more participants than the grid can display.
* @remarks
* To ensure visual stability when tiles are reordered due to track updates,
* the component uses the `useVisualStableUpdate` hook.
* @example
* ```tsx
* <LiveKitRoom>
* <GridLayout tracks={tracks}>
* <ParticipantTile />
* </GridLayout>
* <LiveKitRoom>
* ```
* @public
*/
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
const gridEl = React.createRef<HTMLDivElement>()
const elementProps = React.useMemo(
() => mergeProps(props, { className: 'lk-grid-layout' }),
[props]
)
const { layout } = useGridLayout(gridEl, tracks.length)
const pagination = usePagination(layout.maxTiles, tracks)
useSwipe(gridEl, {
onLeftSwipe: pagination.nextPage,
onRightSwipe: pagination.prevPage,
})
return (
<div
ref={gridEl}
data-lk-pagination={pagination.totalPageCount > 1}
{...elementProps}
>
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
{tracks.length > layout.maxTiles && (
<>
<PaginationIndicator
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
/>
<PaginationControl pagesContainer={gridEl} {...pagination} />
</>
)}
</div>
)
}
@@ -0,0 +1,53 @@
import { GRID_LAYOUTS, selectGridLayout } from '@livekit/components-core'
import type {
GridLayoutDefinition,
GridLayoutInfo,
} from '@livekit/components-core'
import * as React from 'react'
import useSize from '@react-hook/size'
/**
* The `useGridLayout` hook tries to select the best layout to fit all tiles.
* If the available screen space is not enough, it will reduce the number of maximum visible
* tiles and select a layout that still works visually within the given limitations.
* As the order of tiles changes over time, the hook tries to keep visual updates to a minimum
* while trying to display important tiles such as speaking participants or screen shares.
*
* @example
* ```tsx
* const { layout } = useGridLayout(gridElement, trackCount);
* ```
* @public
*/
export function useGridLayout(
/** HTML element that contains the grid. */
gridElement: React.RefObject<HTMLDivElement>,
/** Count of tracks that should get layed out */
trackCount: number,
options: {
gridLayouts?: GridLayoutDefinition[]
} = {}
): { layout: GridLayoutInfo; containerWidth: number; containerHeight: number } {
const gridLayouts = options.gridLayouts ?? GRID_LAYOUTS
const [width, height] = useSize(gridElement)
const layout = selectGridLayout(gridLayouts, trackCount, width, height)
React.useEffect(() => {
if (gridElement.current && layout) {
gridElement.current.style.setProperty(
'--lk-col-count',
layout?.columns.toString()
)
gridElement.current.style.setProperty(
'--lk-row-count',
layout?.rows.toString()
)
}
}, [gridElement, layout])
return {
layout,
containerWidth: width,
containerHeight: height,
}
}
@@ -1,127 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import * as React from 'react'
const useLatest = <T>(current: T) => {
const storedValue = React.useRef(current)
React.useEffect(() => {
storedValue.current = current
})
return storedValue
}
/**
* A React hook that fires a callback whenever ResizeObserver detects a change to its size
* code extracted from https://github.com/jaredLunde/react-hook/blob/master/packages/resize-observer/src/index.tsx in order to not include the polyfill for resize-observer
*
* @internal
*/
export function useResizeObserver<T extends HTMLElement>(
target: React.RefObject<T>,
callback: UseResizeObserverCallback
) {
const resizeObserver = getResizeObserver()
const storedCallback = useLatest(callback)
React.useLayoutEffect(() => {
let didUnsubscribe = false
const targetEl = target.current
if (!targetEl) return
function cb(entry: ResizeObserverEntry, observer: ResizeObserver) {
if (didUnsubscribe) return
storedCallback.current(entry, observer)
}
resizeObserver?.subscribe(targetEl as HTMLElement, cb)
return () => {
didUnsubscribe = true
resizeObserver?.unsubscribe(targetEl as HTMLElement, cb)
}
}, [target.current, resizeObserver, storedCallback])
return resizeObserver?.observer
}
function createResizeObserver() {
let ticking = false
let allEntries: ResizeObserverEntry[] = []
const callbacks: Map<unknown, Array<UseResizeObserverCallback>> = new Map()
if (typeof window === 'undefined') {
return
}
const observer = new ResizeObserver(
(entries: ResizeObserverEntry[], obs: ResizeObserver) => {
allEntries = allEntries.concat(entries)
if (!ticking) {
window.requestAnimationFrame(() => {
const triggered = new Set<Element>()
for (let i = 0; i < allEntries.length; i++) {
if (triggered.has(allEntries[i].target)) continue
triggered.add(allEntries[i].target)
const cbs = callbacks.get(allEntries[i].target)
cbs?.forEach((cb) => cb(allEntries[i], obs))
}
allEntries = []
ticking = false
})
}
ticking = true
}
)
return {
observer,
subscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
observer.observe(target)
const cbs = callbacks.get(target) ?? []
cbs.push(callback)
callbacks.set(target, cbs)
},
unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
const cbs = callbacks.get(target) ?? []
if (cbs.length === 1) {
observer.unobserve(target)
callbacks.delete(target)
return
}
const cbIndex = cbs.indexOf(callback)
if (cbIndex !== -1) cbs.splice(cbIndex, 1)
callbacks.set(target, cbs)
},
}
}
let _resizeObserver: ReturnType<typeof createResizeObserver>
const getResizeObserver = () =>
!_resizeObserver
? (_resizeObserver = createResizeObserver())
: _resizeObserver
export type UseResizeObserverCallback = (
entry: ResizeObserverEntry,
observer: ResizeObserver
) => unknown
export const useSize = (target: React.RefObject<HTMLDivElement>) => {
const [size, setSize] = React.useState({ width: 0, height: 0 })
React.useLayoutEffect(() => {
if (target.current) {
const { width, height } = target.current.getBoundingClientRect()
setSize({ width, height })
}
}, [target.current])
const resizeCallback = React.useCallback(
(entry: ResizeObserverEntry) => setSize(entry.contentRect),
[]
)
// Where the magic happens
useResizeObserver(target, resizeCallback)
return size
}
@@ -4,13 +4,13 @@ import { ParticipantsToggle } from '../../components/controls/Participants/Parti
import { ToolsToggle } from '../../components/controls/ToolsToggle' import { ToolsToggle } from '../../components/controls/ToolsToggle'
import { InfoToggle } from '../../components/controls/InfoToggle' import { InfoToggle } from '../../components/controls/InfoToggle'
import { AdminToggle } from '../../components/AdminToggle' import { AdminToggle } from '../../components/AdminToggle'
import { useSize } from '../../hooks/useResizeObserver'
import { useState, RefObject } from 'react' import { useState, RefObject } from 'react'
import { Dialog, DialogTrigger, Popover } from 'react-aria-components' import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react' import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useSize from '@react-hook/size'
const CONTROL_BAR_BREAKPOINT = 1100 const CONTROL_BAR_BREAKPOINT = 1100
@@ -70,7 +70,7 @@ export const MoreOptions = ({
}: { }: {
parentElement: RefObject<HTMLDivElement> parentElement: RefObject<HTMLDivElement>
}) => { }) => {
const { width: parentWidth } = useSize(parentElement) const parentWidth = useSize(parentElement)[0]
return ( return (
<div <div
className={css({ className={css({
@@ -9,7 +9,6 @@ import { RoomEvent, Track } from 'livekit-client'
import * as React from 'react' import * as React from 'react'
import { useState } from 'react' import { useState } from 'react'
import { import {
CarouselLayout,
ConnectionStateToast, ConnectionStateToast,
FocusLayoutContainer, FocusLayoutContainer,
GridLayout, GridLayout,
@@ -32,6 +31,7 @@ import { RecordingStateToast } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal' import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver' import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useNoiseReduction } from '../hooks/useNoiseReduction' import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { CarouselLayout } from '../components/layout/CarouselLayout'
const LayoutWrapper = styled( const LayoutWrapper = styled(
'div', 'div',
@@ -3,7 +3,7 @@ import { Button } from '@/primitives'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
import { Center, HStack, styled, VStack } from '@/styled-system/jsx' import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
import { Rating } from '@/features/rooms/components/Rating.tsx' import { Rating } from '@/features/rooms/components/Rating.tsx'
import { useLocation } from 'wouter' import { useLocation, useSearchParams } from 'wouter'
// fixme - duplicated with home, refactor in a proper style // fixme - duplicated with home, refactor in a proper style
const Heading = styled('h1', { const Heading = styled('h1', {
@@ -16,17 +16,25 @@ const Heading = styled('h1', {
lineHeight: '2.5rem', lineHeight: '2.5rem',
letterSpacing: '0', letterSpacing: '0',
paddingBottom: '2rem', paddingBottom: '2rem',
textAlign: 'center',
}, },
}) })
export const FeedbackRoute = () => { export const FeedbackRoute = () => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const [, setLocation] = useLocation() const [, setLocation] = useLocation()
const [searchParams] = useSearchParams()
return ( return (
<Screen layout="centered" footer={false}> <Screen layout="centered" footer={false}>
<Center> <Center>
<VStack> <VStack>
<Heading>{t('feedback.heading')}</Heading> <Heading>
{t(
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
)}
</Heading>
<HStack> <HStack>
<Button variant="secondary" onPress={() => window.history.back()}> <Button variant="secondary" onPress={() => window.history.back()}>
{t('feedback.back')} {t('feedback.back')}
@@ -10,6 +10,10 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
const { t, i18n } = useTranslation('settings') const { t, i18n } = useTranslation('settings')
const { user, isLoggedIn, logout } = useUser() const { user, isLoggedIn, logout } = useUser()
const { languagesList, currentLanguage } = useLanguageLabels() const { languagesList, currentLanguage } = useLanguageLabels()
const userDisplay =
user?.full_name && user?.email
? `${user.full_name} (${user.email})`
: user?.email
return ( return (
<Dialog title={t('dialog.heading')} {...props}> <Dialog title={t('dialog.heading')} {...props}>
<H lvl={2}>{t('account.heading')}</H> <H lvl={2}>{t('account.heading')}</H>
@@ -18,7 +22,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
<P> <P>
<Trans <Trans
i18nKey="settings:account.currentlyLoggedAs" i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.full_name ?? user?.email }} values={{ user: userDisplay }}
components={[<Badge />]} components={[<Badge />]}
/> />
</P> </P>
@@ -14,8 +14,8 @@ import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab' import { NotificationsTab } from './tabs/NotificationsTab'
import { GeneralTab } from './tabs/GeneralTab' import { GeneralTab } from './tabs/GeneralTab'
import { AudioTab } from './tabs/AudioTab' import { AudioTab } from './tabs/AudioTab'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useRef } from 'react' import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
const tabsStyle = css({ const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -51,8 +51,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const dialogEl = useRef<HTMLDivElement>(null) const dialogEl = useRef<HTMLDivElement>(null)
const { width } = useSize(dialogEl) const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
const isWideScreen = !width || width >= 800 // fixme - hardcoded 50rem in pixel
return ( return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex"> <Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
@@ -18,6 +18,10 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
const room = useRoomContext() const room = useRoomContext()
const { user, isLoggedIn, logout } = useUser() const { user, isLoggedIn, logout } = useUser()
const [name, setName] = useState(room?.localParticipant.name ?? '') const [name, setName] = useState(room?.localParticipant.name ?? '')
const userDisplay =
user?.full_name && user?.email
? `${user.full_name} (${user.email})`
: user?.email
const handleOnSubmit = () => { const handleOnSubmit = () => {
if (room) room.localParticipant.setName(name) if (room) room.localParticipant.setName(name)
@@ -37,7 +41,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
value={name} value={name}
onChange={setName} onChange={setName}
validate={(value) => { validate={(value) => {
return !value ? <p>{'Votre Nom ne peut pas être vide'}</p> : null return !value ? <p>{t('account.nameError')}</p> : null
}} }}
/> />
<H lvl={2}>{t('account.authentication')}</H> <H lvl={2}>{t('account.authentication')}</H>
@@ -46,7 +50,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
<P> <P>
<Trans <Trans
i18nKey="settings:account.currentlyLoggedAs" i18nKey="settings:account.currentlyLoggedAs"
values={{ user: user?.full_name || user?.email }} values={{ user: userDisplay }}
components={[<Badge />]} components={[<Badge />]}
/> />
</P> </P>
@@ -1,4 +1,4 @@
import { DialogProps, Field, H, Switch } from '@/primitives' import { DialogProps, Field, H, Switch, Text } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs' import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { import {
@@ -174,7 +174,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
</RowWrapper> </RowWrapper>
{/* Safari has a known limitation where its implementation of 'enumerateDevices' does not include audio output devices. {/* Safari has a known limitation where its implementation of 'enumerateDevices' does not include audio output devices.
To prevent errors or an empty selection list, we only render the speakers selection field on non-Safari browsers. */} To prevent errors or an empty selection list, we only render the speakers selection field on non-Safari browsers. */}
{!isSafari() && ( {!isSafari() ? (
<RowWrapper heading={t('audio.speakers.heading')}> <RowWrapper heading={t('audio.speakers.heading')}>
<Field <Field
type="select" type="select"
@@ -193,6 +193,13 @@ export const AudioTab = ({ id }: AudioTabProps) => {
/> />
<SoundTester /> <SoundTester />
</RowWrapper> </RowWrapper>
) : (
<RowWrapper heading={t('audio.speakers.heading')}>
<Text variant="warning" margin="md">
{t('audio.speakers.safariWarning')}
</Text>
<div />
</RowWrapper>
)} )}
{noiseReductionAvailable && ( {noiseReductionAvailable && (
<RowWrapper heading={t('audio.noiseReduction.heading')} beta> <RowWrapper heading={t('audio.noiseReduction.heading')} beta>
@@ -7,7 +7,7 @@ export const initializeSupportSession = (user: ApiUser) => {
if (!Crisp.isCrispInjected()) return if (!Crisp.isCrispInjected()) return
const { id, email } = user const { id, email } = user
Crisp.setTokenId(`meet-${id}`) Crisp.setTokenId(`meet-${id}`)
Crisp.user.setEmail(email) if (email) Crisp.user.setEmail(email)
} }
export const terminateSupportSession = () => { export const terminateSupportSession = () => {
@@ -24,11 +24,13 @@
}, },
"transcript": { "transcript": {
"started": "{{name}} hat die Transkription des Treffens gestartet.", "started": "{{name}} hat die Transkription des Treffens gestartet.",
"stopped": "{{name}} hat die Transkription des Treffens gestoppt." "stopped": "{{name}} hat die Transkription des Treffens gestoppt.",
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
}, },
"screenRecording": { "screenRecording": {
"started": "{{name}} hat die Aufzeichnung des Treffens gestartet.", "started": "{{name}} hat die Aufzeichnung des Treffens gestartet.",
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt." "stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt.",
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
}, },
"recordingSave": { "recordingSave": {
"transcript": { "transcript": {
+6 -2
View File
@@ -1,7 +1,7 @@
{ {
"error": { "error": {
"title": "Aufzeichnung nicht verfügbar", "title": "Aufzeichnung nicht verfügbar",
"body": "Diese Aufzeichnung konnte nicht gefunden werden oder wurde gelöscht." "body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur der Organisator der Besprechung hat Zugriff darauf. Wenden Sie sich bei Bedarf gerne an ihn."
}, },
"expired": { "expired": {
"title": "Aufzeichnung abgelaufen", "title": "Aufzeichnung abgelaufen",
@@ -19,6 +19,10 @@
"title": "Ihre Aufzeichnung ist bereit!", "title": "Ihre Aufzeichnung ist bereit!",
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.", "body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.", "expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
"button": "Herunterladen" "button": "Herunterladen",
"warning": {
"title": "Linkfreigabe deaktiviert",
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen."
}
} }
} }
+23 -1
View File
@@ -1,6 +1,9 @@
{ {
"feedback": { "feedback": {
"heading": "Sie haben das Meeting verlassen", "heading": {
"normal": "Sie haben das Meeting verlassen",
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten"
},
"home": "Zur Startseite zurückkehren", "home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten" "back": "Dem Meeting erneut beitreten"
}, },
@@ -57,6 +60,19 @@
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.", "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." "permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten."
}, },
"mediaErrorDialog": {
"DeviceInUse": {
"title": {
"audioinput": "Mikrofon konnte nicht aktiviert werden",
"videoinput": "Kamera konnte nicht aktiviert werden"
},
"body": {
"audioinput": "Ihr Mikrofon wird bereits in einem anderen Tab oder von einer anderen Anwendung verwendet, was die Aktivierung in diesem Videoanruf verhindert. Wenn Sie möchten, schließen Sie den anderen Tab oder die Anwendung, um es hier zu verwenden.",
"videoinput": "Ihre Kamera wird bereits in einem anderen Tab oder von einer anderen Anwendung verwendet, was die Aktivierung in diesem Videoanruf verhindert. Wenn Sie möchten, schließen Sie den anderen Tab oder die Anwendung, um sie hier zu verwenden."
}
},
"close": "OK"
},
"error": { "error": {
"createRoom": { "createRoom": {
"heading": "Authentifizierung erforderlich", "heading": "Authentifizierung erforderlich",
@@ -379,6 +395,12 @@
}, },
"any": { "any": {
"started": "Aufzeichnung läuft" "started": "Aufzeichnung läuft"
},
"limitReachedAlert": {
"title": "Aufnahmelimit überschritten",
"description": "Die Aufnahme hat die zulässige Höchstdauer{{duration_message}} überschritten. Sie wird jetzt automatisch gespeichert.",
"durationMessage": " von {{duration}}",
"button": "OK"
} }
}, },
"participantTileFocus": { "participantTileFocus": {
+4 -2
View File
@@ -4,7 +4,8 @@
"heading": "Konto", "heading": "Konto",
"youAreNotLoggedIn": "Sie sind nicht angemeldet.", "youAreNotLoggedIn": "Sie sind nicht angemeldet.",
"nameLabel": "Ihr Name", "nameLabel": "Ihr Name",
"authentication": "Authentifizierung" "authentication": "Authentifizierung",
"nameError": "Ihr Name darf nicht leer sein"
}, },
"audio": { "audio": {
"microphone": { "microphone": {
@@ -24,7 +25,8 @@
"heading": "Lautsprecher", "heading": "Lautsprecher",
"label": "Wählen Sie Ihre Audioausgabe", "label": "Wählen Sie Ihre Audioausgabe",
"test": "Testen", "test": "Testen",
"ongoingTest": "Soundtest läuft…" "ongoingTest": "Soundtest läuft…",
"safariWarning": "Die Lautsprecherauswahl ist auf Safari aufgrund von Browser-Einschränkungen noch nicht verfügbar."
}, },
"permissionsRequired": "Berechtigungen erforderlich" "permissionsRequired": "Berechtigungen erforderlich"
}, },
@@ -24,11 +24,13 @@
}, },
"transcript": { "transcript": {
"started": "{{name}} started the meeting transcription.", "started": "{{name}} started the meeting transcription.",
"stopped": "{{name}} stopped the meeting transcription." "stopped": "{{name}} stopped the meeting transcription.",
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved."
}, },
"screenRecording": { "screenRecording": {
"started": "{{name}} started the meeting recording.", "started": "{{name}} started the meeting recording.",
"stopped": "{{name}} stopped the meeting recording." "stopped": "{{name}} stopped the meeting recording.",
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved."
}, },
"recordingSave": { "recordingSave": {
"transcript": { "transcript": {
+6 -2
View File
@@ -1,7 +1,7 @@
{ {
"error": { "error": {
"title": "Recording unavailable", "title": "Recording unavailable",
"body": "This recording could not be found or was deleted." "body": "The recording is unavailable or may have been deleted. Only the meeting organizer can access it. Feel free to contact them if needed."
}, },
"expired": { "expired": {
"title": "Recording expired", "title": "Recording expired",
@@ -19,6 +19,10 @@
"title": "Your recording is ready!", "title": "Your recording is ready!",
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.", "body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
"expiration": "Attention, this recording will expire after {{expiration_days}} day(s).", "expiration": "Attention, this recording will expire after {{expiration_days}} day(s).",
"button": "Download" "button": "Download",
"warning": {
"title": "Link sharing disabled",
"body": "Sharing the recording via link is not yet available. Only organizers can download it."
}
} }
} }
+23 -1
View File
@@ -1,6 +1,9 @@
{ {
"feedback": { "feedback": {
"heading": "You have left the meeting", "heading": {
"normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device"
},
"home": "Return to home", "home": "Return to home",
"back": "Rejoin the meeting" "back": "Rejoin the meeting"
}, },
@@ -57,6 +60,19 @@
"description": "Share this link with people you want to invite to the meeting.", "description": "Share this link with people you want to invite to the meeting.",
"permissions": "People with this link do not need your permission to join this meeting." "permissions": "People with this link do not need your permission to join this meeting."
}, },
"mediaErrorDialog": {
"DeviceInUse": {
"title": {
"audioinput": "Microphone Activation Failed",
"videoinput": "Camera Activation Failed"
},
"body": {
"audioinput": "Your microphone is already in use by another tab or application, which prevents it from being activated in this video call. If you wish, close the other tab or application to use it here.",
"videoinput": "Your camera is already in use by another tab or application, which prevents it from being activated in this video call. If you wish, close the other tab or application to use it here."
}
},
"close": "OK"
},
"error": { "error": {
"createRoom": { "createRoom": {
"heading": "Authentication Required", "heading": "Authentication Required",
@@ -379,6 +395,12 @@
}, },
"any": { "any": {
"started": "Recording in progress" "started": "Recording in progress"
},
"limitReachedAlert": {
"title": "Recording limit exceeded",
"description": "The recording has exceeded the maximum allowed duration{{duration_message}}. It will now be saved automatically.",
"durationMessage": " of {{duration}}",
"button": "OK"
} }
}, },
"participantTileFocus": { "participantTileFocus": {
+4 -2
View File
@@ -4,7 +4,8 @@
"heading": "Account", "heading": "Account",
"youAreNotLoggedIn": "You are not logged in.", "youAreNotLoggedIn": "You are not logged in.",
"nameLabel": "Your Name", "nameLabel": "Your Name",
"authentication": "Authentication" "authentication": "Authentication",
"nameError": "Your name cannot be empty"
}, },
"audio": { "audio": {
"microphone": { "microphone": {
@@ -24,7 +25,8 @@
"heading": "Speakers", "heading": "Speakers",
"label": "Select your audio output", "label": "Select your audio output",
"test": "Test", "test": "Test",
"ongoingTest": "Testing sound…" "ongoingTest": "Testing sound…",
"safariWarning": "Speaker selection is not available yet on Safari due to browser limitations."
}, },
"permissionsRequired": "Permissions required" "permissionsRequired": "Permissions required"
}, },
@@ -24,11 +24,13 @@
}, },
"transcript": { "transcript": {
"started": "{{name}} a démarré la transcription de la réunion.", "started": "{{name}} a démarré la transcription de la réunion.",
"stopped": "{{name}} a arrêté la transcription de la réunion." "stopped": "{{name}} a arrêté la transcription de la réunion.",
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée."
}, },
"screenRecording": { "screenRecording": {
"started": "{{name}} a démarré l'enregistrement de la réunion.", "started": "{{name}} a démarré l'enregistrement de la réunion.",
"stopped": "{{name}} a arrêté l'enregistrement de la réunion." "stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé."
}, },
"recordingSave": { "recordingSave": {
"transcript": { "transcript": {
+6 -2
View File
@@ -1,7 +1,7 @@
{ {
"error": { "error": {
"title": "Enregistrement indisponible", "title": "Enregistrement indisponible",
"body": "Cet enregistrement est introuvable ou a été supprimé." "body": "Lenregistrement est introuvable. Seul lorganisateur de la réunion peut y accéder. Nhésitez pas à le contacter si besoin."
}, },
"expired": { "expired": {
"title": "Enregistrement expiré", "title": "Enregistrement expiré",
@@ -19,6 +19,10 @@
"title": "Votre enregistrement est prêt !", "title": "Votre enregistrement est prêt !",
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.", "body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
"expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).", "expiration": "Attention cet enregistrement expirera au bout de {{expiration_days}} jour(s).",
"button": "Télécharger" "button": "Télécharger",
"warning": {
"title": "Partage via lien désactivé",
"body": "Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
}
} }
} }
+23 -1
View File
@@ -1,6 +1,9 @@
{ {
"feedback": { "feedback": {
"heading": "Vous avez quitté la réunion", "heading": {
"normal": "Vous avez quitté la réunion",
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil"
},
"home": "Retourner à l'accueil", "home": "Retourner à l'accueil",
"back": "Réintégrer la réunion" "back": "Réintégrer la réunion"
}, },
@@ -57,6 +60,19 @@
"description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.", "description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.",
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion." "permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
}, },
"mediaErrorDialog": {
"DeviceInUse": {
"title": {
"audioinput": "Activation microphone impossible",
"videoinput": "Activation caméra impossible"
},
"body": {
"audioinput": "Votre microphone est déjà utilisé dans un autre onglet ou une autre application, ce qui empêche son activation dans cette visioconférence. Si vous le souhaitez, fermez l'autre onglet ou application pour l'utiliser ici.",
"videoinput": "Votre caméra est déjà utilisée dans un autre onglet ou une autre application, ce qui empêche son activation dans cette visioconférence. Si vous le souhaitez, fermez l'autre onglet ou application pour l'utiliser ici."
}
},
"close": "OK"
},
"error": { "error": {
"createRoom": { "createRoom": {
"heading": "Authentification requise", "heading": "Authentification requise",
@@ -379,6 +395,12 @@
}, },
"any": { "any": {
"started": "Enregistrement en cours" "started": "Enregistrement en cours"
},
"limitReachedAlert": {
"title": "Limite d'enregistrement dépassée",
"description": "L'enregistrement a dépassé la durée maximale autorisée{{duration_message}}. Il va maintenant être automatiquement sauvegardé.",
"durationMessage": " de {{duration}}",
"button": "OK"
} }
}, },
"participantTileFocus": { "participantTileFocus": {
+4 -2
View File
@@ -4,7 +4,8 @@
"heading": "Compte", "heading": "Compte",
"youAreNotLoggedIn": "Vous n'êtes pas connecté.", "youAreNotLoggedIn": "Vous n'êtes pas connecté.",
"nameLabel": "Votre Nom", "nameLabel": "Votre Nom",
"authentication": "Authentification" "authentication": "Authentification",
"nameError": "Votre Nom ne peut pas être vide"
}, },
"audio": { "audio": {
"microphone": { "microphone": {
@@ -24,7 +25,8 @@
"heading": "Haut-parleurs", "heading": "Haut-parleurs",
"label": "Sélectionner votre sortie audio", "label": "Sélectionner votre sortie audio",
"test": "Tester", "test": "Tester",
"ongoingTest": "Test du son…" "ongoingTest": "Test du son…",
"safariWarning": "La sélection du haut-parleur n'est pas encore disponible sur Safari en raison de limitations du navigateur."
}, },
"permissionsRequired": "Autorisations nécessaires" "permissionsRequired": "Autorisations nécessaires"
}, },
@@ -24,11 +24,13 @@
}, },
"transcript": { "transcript": {
"started": "{{name}} is de transcriptie van de vergadering gestart.", "started": "{{name}} is de transcriptie van de vergadering gestart.",
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt." "stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen."
}, },
"screenRecording": { "screenRecording": {
"started": "{{name}} is begonnen met het opnemen van de vergadering.", "started": "{{name}} is begonnen met het opnemen van de vergadering.",
"stopped": "{{name}} is gestopt met het opnemen van de vergadering." "stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen."
}, },
"recordingSave": { "recordingSave": {
"transcript": { "transcript": {
+6 -2
View File
@@ -1,7 +1,7 @@
{ {
"error": { "error": {
"title": "Opname niet beschikbaar", "title": "Opname niet beschikbaar",
"body": "Deze opname is niet gevonden of is verwijderd." "body": "De opname is niet beschikbaar of is mogelijk verwijderd. Alleen de organisator van de vergadering heeft toegang. Neem gerust contact met hem of haar op als dat nodig is."
}, },
"expired": { "expired": {
"title": "Opname verlopen", "title": "Opname verlopen",
@@ -19,6 +19,10 @@
"title": "Je opname is klaar!", "title": "Je opname is klaar!",
"body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.", "body": "Opname van de vergadering <b>{{room}}</b> op {{created_at}}.",
"expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).", "expiration": "Let op, deze opname verloopt na {{expiration_days}} dag(en).",
"button": "Downloaden" "button": "Downloaden",
"warning": {
"title": "Delen via link uitgeschakeld",
"body": "Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
}
} }
} }
+23 -1
View File
@@ -1,6 +1,9 @@
{ {
"feedback": { "feedback": {
"heading": "Je hebt de vergadering verlaten", "heading": {
"normal": "Je hebt de vergadering verlaten",
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend"
},
"home": "Keer terug naar het hoofdscherm", "home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan" "back": "Sluit weer bij de vergadering aan"
}, },
@@ -57,6 +60,19 @@
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.", "description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering." "permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
}, },
"mediaErrorDialog": {
"DeviceInUse": {
"title": {
"audioinput": "Microfoon activeren mislukt",
"videoinput": "Camera activeren mislukt"
},
"body": {
"audioinput": "Je microfoon wordt al gebruikt door een ander tabblad of een andere toepassing, waardoor deze niet geactiveerd kan worden in dit videogesprek. Sluit indien gewenst het andere tabblad of de toepassing om je microfoon hier te gebruiken.",
"videoinput": "Je camera wordt al gebruikt door een ander tabblad of een andere toepassing, waardoor deze niet geactiveerd kan worden in dit videogesprek. Sluit indien gewenst het andere tabblad of de toepassing om je camera hier te gebruiken."
}
},
"close": "OK"
},
"error": { "error": {
"createRoom": { "createRoom": {
"heading": "Verificatie vereist", "heading": "Verificatie vereist",
@@ -379,6 +395,12 @@
}, },
"any": { "any": {
"started": "Opname bezig" "started": "Opname bezig"
},
"limitReachedAlert": {
"title": "Opnamelimiet overschreden",
"description": "De opname heeft de maximaal toegestane duur{{duration_message}} overschreden. Deze wordt nu automatisch opgeslagen.",
"durationMessage": " van {{duration}}",
"button": "OK"
} }
}, },
"participantTileFocus": { "participantTileFocus": {
+4 -2
View File
@@ -4,7 +4,8 @@
"heading": "Account", "heading": "Account",
"youAreNotLoggedIn": "U bent niet ingelogd.", "youAreNotLoggedIn": "U bent niet ingelogd.",
"nameLabel": "Uw naam", "nameLabel": "Uw naam",
"authentication": "Authenticatie" "authentication": "Authenticatie",
"nameError": "Uw naam mag niet leeg zijn"
}, },
"audio": { "audio": {
"microphone": { "microphone": {
@@ -24,7 +25,8 @@
"heading": "Luidsprekers", "heading": "Luidsprekers",
"label": "Selecteer uw audio-uitvoer", "label": "Selecteer uw audio-uitvoer",
"test": "Test", "test": "Test",
"ongoingTest": "Testgeluid ..." "ongoingTest": "Testgeluid ...",
"safariWarning": "Luidsprekerselectie is nog niet beschikbaar op Safari vanwege browserbeperkingen."
}, },
"permissionsRequired": "Machtigingen vereist" "permissionsRequired": "Machtigingen vereist"
}, },
+3
View File
@@ -53,6 +53,9 @@ export const text = cva({
note: { note: {
color: 'default.subtle-text', color: 'default.subtle-text',
}, },
warning: {
color: 'danger.subtle-text',
},
smNote: { smNote: {
color: 'default.subtle-text', color: 'default.subtle-text',
textStyle: 'sm', textStyle: 'sm',
+5
View File
@@ -43,6 +43,11 @@ export const routes: Record<
feedback: { feedback: {
name: 'feedback', name: 'feedback',
path: '/feedback', path: '/feedback',
to: (params: { duplicateIdentity?: false }) =>
'/feedback' +
(params.duplicateIdentity
? `?duplicateIdentity=${params?.duplicateIdentity}`
: ''),
Component: FeedbackRoute, Component: FeedbackRoute,
}, },
legalTerms: { legalTerms: {
+5
View File
@@ -29,6 +29,11 @@
{% endblocktrans %} {% endblocktrans %}
{% endif %} {% endif %}
</mj-text> </mj-text>
<mj-text>
{% blocktrans %}
Sharing the recording via link is not yet available. Only organizers can download it.
{% endblocktrans %}
</mj-text>
<mj-text> <mj-text>
<p>{% trans "To keep this recording permanently:" %}</p> <p>{% trans "To keep this recording permanently:" %}</p>
<ol> <ol>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "0.1.28", "version": "0.1.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mail_mjml", "name": "mail_mjml",
"version": "0.1.28", "version": "0.1.30",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.5.4", "@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "0.1.28", "version": "0.1.30",
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "0.1.28", "version": "0.1.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sdk", "name": "sdk",
"version": "0.1.28", "version": "0.1.30",
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
"./library", "./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sdk", "name": "sdk",
"version": "0.1.28", "version": "0.1.30",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "summary" name = "summary"
version = "0.1.28" version = "0.1.30"
dependencies = [ dependencies = [
"fastapi[standard]>=0.105.0", "fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0", "uvicorn>=0.24.0",
+13 -2
View File
@@ -20,6 +20,9 @@ class TaskCreation(BaseModel):
email: str email: str
sub: str sub: str
version: Optional[int] = 2 version: Optional[int] = 2
room: Optional[str]
recording_date: Optional[str]
recording_time: Optional[str]
router = APIRouter(prefix="/tasks") router = APIRouter(prefix="/tasks")
@@ -33,8 +36,16 @@ async def create_task(request: TaskCreation):
request.filename, request.email, request.sub request.filename, request.email, request.sub
) )
else: else:
task = process_audio_transcribe_summarize_v2.delay( task = process_audio_transcribe_summarize_v2.apply_async(
request.filename, request.email, request.sub, time.time() args=[
request.filename,
request.email,
request.sub,
time.time(),
request.room,
request.recording_date,
request.recording_time,
]
) )
return {"id": task.id, "message": "Task created"} return {"id": task.id, "message": "Task created"}
+23 -10
View File
@@ -55,8 +55,8 @@ def get_analytics():
return Analytics() return Analytics()
class TasksTracker: class MetadataManager:
"""Tracks task execution metadata and analytics for background tasks.""" """A Redis-based metadata manager for storing and retrieving task metadata."""
def __init__(self): def __init__(self):
"""Initialize the task tracker with analytics client.""" """Initialize the task tracker with analytics client."""
@@ -70,13 +70,27 @@ class TasksTracker:
return f"{self._key_prefix}{task_id}" return f"{self._key_prefix}{task_id}"
def _save_metadata(self, task_id, metadata): def _save_metadata(self, task_id, metadata):
"""Wip.""" """Save metadata for a specific task to Redis."""
self._redis.hset(self._get_redis_key(task_id), mapping=metadata) self._redis.hset(self._get_redis_key(task_id), mapping=metadata)
@staticmethod
def _convert_value(value):
"""Convert a string value to the most appropriate Python type."""
try:
return int(value)
except ValueError:
try:
return float(value)
except ValueError:
return value
def _get_metadata(self, task_id): def _get_metadata(self, task_id):
"""Wip.""" """Retrieve and parse metadata for a specific task from Redis."""
raw_metadata = self._redis.hgetall(self._get_redis_key(task_id)) raw_metadata = self._redis.hgetall(self._get_redis_key(task_id))
return {k.decode("utf-8"): v.decode("utf-8") for k, v in raw_metadata.items()} return {
k.decode("utf-8"): self._convert_value(v.decode("utf-8"))
for k, v in raw_metadata.items()
}
def has_task_id(self, task_id): def has_task_id(self, task_id):
"""Check if task_id exists in tasks metadata cache.""" """Check if task_id exists in tasks metadata cache."""
@@ -93,12 +107,13 @@ class TasksTracker:
"retries": 0, "retries": 0,
} }
_required_args_count = 4 _required_args_count = 7
if len(task_args) != _required_args_count: if len(task_args) != _required_args_count:
logger.error("Invalid number of arguments.") logger.error("Invalid number of arguments.")
return return
filename, email, _, received_at = task_args filename, email, _, received_at, *_ = task_args
initial_metadata = { initial_metadata = {
**initial_metadata, **initial_metadata,
"filename": filename, "filename": filename,
@@ -182,9 +197,7 @@ class TasksTracker:
metadata = self._get_metadata(task_id) metadata = self._get_metadata(task_id)
if "start_time" in metadata: if "start_time" in metadata:
metadata["execution_time"] = str(round( metadata["execution_time"] = round(time.time() - metadata["start_time"], 2)
time.time() - float(metadata["start_time"]), 2
))
del metadata["start_time"] del metadata["start_time"]
metadata["task_id"] = task_id metadata["task_id"] = task_id
+12
View File
@@ -0,0 +1,12 @@
"""Celery Config."""
# https://github.com/danihodovic/celery-exporter
# Enable task events for Prometheus monitoring via celery-exporter.
# worker_send_task_events: Sends task lifecycle events (e.g., started, succeeded),
# allowing the exporter to track task execution metrics and durations.
worker_send_task_events = True
# task_send_sent_event: Sends an event when a task is dispatched to the broker,
# enabling full lifecycle tracking from submission to completion (including queue time).
task_send_sent_event = True
+35 -12
View File
@@ -1,10 +1,13 @@
"""Celery workers.""" """Celery workers."""
# ruff: noqa: PLR0913
import json import json
import os import os
import tempfile import tempfile
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional
import openai import openai
import sentry_sdk import sentry_sdk
@@ -16,14 +19,14 @@ from requests import Session, exceptions
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util import Retry from urllib3.util import Retry
from summary.core.analytics import TasksTracker, get_analytics from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings from summary.core.config import get_settings
from summary.core.prompt import get_instructions from summary.core.prompt import get_instructions
settings = get_settings() settings = get_settings()
analytics = get_analytics() analytics = get_analytics()
tasks_tracker = TasksTracker() metadata_manager = MetadataManager()
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
@@ -34,6 +37,8 @@ celery = Celery(
broker_connection_retry_on_startup=True, broker_connection_retry_on_startup=True,
) )
celery.config_from_object("summary.core.celery_config")
if settings.sentry_dsn and settings.sentry_is_enabled: if settings.sentry_dsn and settings.sentry_is_enabled:
@signals.celeryd_init.connect @signals.celeryd_init.connect
@@ -136,19 +141,19 @@ def post_with_retries(url, data):
def task_started(task_id=None, task=None, args=None, **kwargs): def task_started(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins.""" """Signal handler called before task execution begins."""
task_args = args or [] task_args = args or []
tasks_tracker.create(task_id, task_args) metadata_manager.create(task_id, task_args)
@signals.task_retry.connect @signals.task_retry.connect
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs): def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
"""Signal handler called when task execution retries.""" """Signal handler called when task execution retries."""
tasks_tracker.retry(request.id) metadata_manager.retry(request.id)
@signals.task_failure.connect @signals.task_failure.connect
def task_failure_handler(task_id, exception=None, **kwargs): def task_failure_handler(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently.""" """Signal handler called when task execution fails permanently."""
tasks_tracker.capture(task_id, settings.posthog_event_failure) metadata_manager.capture(task_id, settings.posthog_event_failure)
@celery.task(max_retries=settings.celery_max_retries) @celery.task(max_retries=settings.celery_max_retries)
@@ -233,7 +238,14 @@ def process_audio_transcribe_summarize(filename: str, email: str, sub: str):
max_retries=settings.celery_max_retries, max_retries=settings.celery_max_retries,
) )
def process_audio_transcribe_summarize_v2( def process_audio_transcribe_summarize_v2(
self, filename: str, email: str, sub: str, received_at: float self,
filename: str,
email: str,
sub: str,
received_at: float,
room: Optional[str],
recording_date: Optional[str],
recording_time: Optional[str],
): ):
"""Process an audio file by transcribing it and generating a summary. """Process an audio file by transcribing it and generating a summary.
@@ -267,9 +279,12 @@ def process_audio_transcribe_summarize_v2(
logger.debug("Recording filepath: %s", temp_file_path) logger.debug("Recording filepath: %s", temp_file_path)
audio_file = File(temp_file_path) audio_file = File(temp_file_path)
tasks_tracker.track(task_id, {"audio_length": audio_file.info.length}) metadata_manager.track(task_id, {"audio_length": audio_file.info.length})
if audio_file.info.length > settings.recording_max_duration: if (
settings.recording_max_duration is not None
and audio_file.info.length > settings.recording_max_duration
):
error_msg = "Recording too long: %.2fs > %.2fs limit" % ( error_msg = "Recording too long: %.2fs > %.2fs limit" % (
audio_file.info.length, audio_file.info.length,
settings.recording_max_duration, settings.recording_max_duration,
@@ -291,7 +306,7 @@ def process_audio_transcribe_summarize_v2(
transcription = openai_client.audio.transcriptions.create( transcription = openai_client.audio.transcriptions.create(
model=settings.openai_asr_model, file=audio_file model=settings.openai_asr_model, file=audio_file
) )
tasks_tracker.track( metadata_manager.track(
task_id, task_id,
{ {
"transcription_time": round( "transcription_time": round(
@@ -312,10 +327,18 @@ def process_audio_transcribe_summarize_v2(
else format_segments(transcription) else format_segments(transcription)
) )
tasks_tracker.track_transcription_metadata(task_id, transcription) metadata_manager.track_transcription_metadata(task_id, transcription)
if not room or not recording_date or not recording_time:
title = settings.document_default_title
else:
title = settings.document_title_template.format(
room=room,
room_recording_date=recording_date,
room_recording_time=recording_time,
)
data = { data = {
"title": "Transcription", "title": title,
"content": formatted_transcription, "content": formatted_transcription,
"email": email, "email": email,
"sub": sub, "sub": sub,
@@ -329,6 +352,6 @@ def process_audio_transcribe_summarize_v2(
logger.info("Webhook submitted successfully. Status: %s", response.status_code) logger.info("Webhook submitted successfully. Status: %s", response.status_code)
logger.debug("Response body: %s", response.text) logger.debug("Response body: %s", response.text)
tasks_tracker.capture(task_id, settings.posthog_event_success) metadata_manager.capture(task_id, settings.posthog_event_success)
# TODO - integrate summarize the transcript and create a new document. # TODO - integrate summarize the transcript and create a new document.
+8 -2
View File
@@ -1,7 +1,7 @@
"""Application configuration and settings.""" """Application configuration and settings."""
from functools import lru_cache from functools import lru_cache
from typing import Annotated, Optional, List from typing import Annotated, List, Optional
from fastapi import Depends from fastapi import Depends
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
app_api_token: str app_api_token: str
# Audio recordings # Audio recordings
recording_max_duration: int = 5400 # 1h30 recording_max_duration: Optional[int] = None
# Celery settings # Celery settings
celery_broker_url: str = "redis://redis/0" celery_broker_url: str = "redis://redis/0"
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
webhook_api_token: str webhook_api_token: str
webhook_url: str webhook_url: str
# Output related settings
document_default_title: Optional[str] = "Transcription"
document_title_template: Optional[str] = (
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
)
# Sentry # Sentry
sentry_is_enabled: bool = False sentry_is_enabled: bool = False
sentry_dsn: Optional[str] = None sentry_dsn: Optional[str] = None