Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7fdd7c317 | |||
| 201069aa4c | |||
| b6a5b1a805 | |||
| f3af637fd6 | |||
| de3a5aa404 | |||
| 5e9d20e685 | |||
| b54445739a | |||
| 7c67bacd94 | |||
| 1fd1b184ee | |||
| adb517a336 | |||
| eec9ff9f26 | |||
| e0258a1765 | |||
| 872ce1ecc6 | |||
| e2c3b745ca | |||
| 965d823d08 | |||
| 1db189ace2 | |||
| 199e0908e9 | |||
| 8518f83211 | |||
| 0240d85837 | |||
| 162896c93c | |||
| 483a219ac4 | |||
| 1b26dea178 | |||
| bdaf4245da | |||
| be63993ba2 | |||
| 3d245c3bd4 | |||
| 66a36eff73 | |||
| 387bc2e1f4 | |||
| d44b45b6aa | |||
| 224b98fd9a | |||
| 031852d0b1 | |||
| 24915b0485 | |||
| 0862203d5d | |||
| ee604abe00 | |||
| 26d668b478 | |||
| 04081f04fc | |||
| f7268c507b | |||
| cadb20793a | |||
| 8a417806e4 | |||
| 223c744e3f | |||
| f67335f306 | |||
| 4eb7f29f8e | |||
| 912bac8756 | |||
| 3c97418a70 | |||
| 26a90456f7 | |||
| d068558c8f | |||
| dac6bfe142 | |||
| 6b3e5d747a | |||
| 3066e3a83c | |||
| 5e05b6b2a5 | |||
| d075d60d19 | |||
| 7c631bb76f | |||
| 59cd1f766a | |||
| f0a17b1ce1 | |||
| 17c486f7bf | |||
| 85bde9633f | |||
| 0eb715b0cd | |||
| b0617dcfed | |||
| 6c4c44e933 | |||
| 16dde229cc | |||
| d01d6dd9d1 | |||
| 2a39978245 | |||
| d85ed031a9 | |||
| 5c8c81f97b | |||
| 3368a9b6af | |||
| e87d0519ff | |||
| 731f0471aa | |||
| e458272745 | |||
| d71e417d58 | |||
| d91f343ba9 | |||
| 4fc0744433 | |||
| 93e3f05348 | |||
| cbabcb877b | |||
| 30cd6573ef | |||
| 921d69031e | |||
| 1716e11900 | |||
| 6e55013b15 | |||
| 21bed40484 | |||
| 5959e82bf0 | |||
| b5113580b3 | |||
| 053e4bc7b9 | |||
| 9e57c25a25 | |||
| dfbcbed485 | |||
| 9c28e1b266 | |||
| 2a586445b5 | |||
| 75ffb7f5f7 | |||
| 6a1c85809d | |||
| 70d250cc9c | |||
| 988e5aa256 | |||
| d3178eff5d | |||
| 9c840a4e06 | |||
| e6754b49e0 | |||
| ec586eaab4 |
@@ -1,35 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'preprod'
|
||||
- 'production'
|
||||
|
||||
|
||||
jobs:
|
||||
notify-argocd:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
|
||||
|
||||
start-test-on-preprod:
|
||||
needs:
|
||||
- notify-argocd
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.event.ref, 'refs/tags/preprod')
|
||||
steps:
|
||||
-
|
||||
name: Debug
|
||||
run: |
|
||||
echo "Start test when preprod is ready"
|
||||
@@ -0,0 +1,2 @@
|
||||
web: bin/buildpack_start.sh
|
||||
postdeploy: python manage.py migrate
|
||||
@@ -33,9 +33,9 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- End-to-end encryption (coming soon)
|
||||
- Meeting recording (coming soon)
|
||||
- Meeting recording
|
||||
- Meeting transcription (currently in beta)
|
||||
- Telephony integration (in development)
|
||||
- Telephony integration
|
||||
- Secure participation with robust authentication and access control
|
||||
- LiveKit Advances features including :
|
||||
- speaker detection
|
||||
@@ -111,6 +111,7 @@ Come help us make La Suite Meet even better. We're growing fast and [would love
|
||||
## Credits
|
||||
|
||||
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
|
||||
This project is tested with BrowserStack.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit # always exit on error
|
||||
set -o pipefail # don't ignore exit codes when piping output
|
||||
|
||||
echo "-----> Running post-compile script"
|
||||
|
||||
# Cleanup
|
||||
rm -rf docker docs env.d gitlint
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit # always exit on error
|
||||
set -o pipefail # don't ignore exit codes when piping output
|
||||
|
||||
echo "-----> Running post-frontend script"
|
||||
|
||||
# Move the frontend build to the nginx root and clean up
|
||||
mkdir -p build/
|
||||
mv src/frontend/dist build/frontend-out
|
||||
|
||||
mv src/backend/* ./
|
||||
mv src/nginx/* ./
|
||||
|
||||
echo "3.13" > .python-version
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start the Django backend server
|
||||
gunicorn -b :8000 meet.wsgi:application --log-file - &
|
||||
|
||||
# Start the Nginx server
|
||||
bin/run &
|
||||
|
||||
# if the current shell is killed, also terminate all its children
|
||||
trap "pkill SIGTERM -P $$" SIGTERM
|
||||
|
||||
# wait for a single child to finish,
|
||||
wait -n
|
||||
# then kill all the other tasks
|
||||
pkill -P $$
|
||||
@@ -11,15 +11,21 @@ RUN npm ci
|
||||
COPY .dockerignore ./.dockerignore
|
||||
COPY ./src/frontend/ .
|
||||
|
||||
### ---- Front-end builder image ----
|
||||
# ---- Front-end builder image ----
|
||||
FROM frontend-deps AS meet-builder
|
||||
|
||||
WORKDIR /home/frontend
|
||||
|
||||
ENV VITE_APP_TITLE="Visio"
|
||||
ENV VITE_BUILD_SOURCEMAP="true"
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Inject PostHog sourcemap metadata into the built assets
|
||||
# This metadata is essential for correctly mapping errors to source maps in production
|
||||
RUN set -e && \
|
||||
npx @posthog/cli sourcemap inject --directory ./dist/assets
|
||||
|
||||
COPY ./docker/dinum-frontend/dinum-styles.css \
|
||||
./dist/assets/
|
||||
|
||||
|
||||
@@ -35,9 +35,6 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
|
||||
+128
-123
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
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:
|
||||
|
||||
@@ -134,6 +134,8 @@ keycloak-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 :
|
||||
|
||||
```
|
||||
@@ -152,7 +154,7 @@ You can find these values in **examples/keycloak.values.yaml**
|
||||
|
||||
### 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 :
|
||||
|
||||
@@ -194,7 +196,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
||||
|
||||
### 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
|
||||
@@ -215,14 +217,11 @@ DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
## 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/
|
||||
@@ -243,123 +242,129 @@ meet <none> meet.127.0.0.1.nip.io localhost 80, 44
|
||||
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||
```
|
||||
|
||||
You can use Visio on https://meet.127.0.0.1.nip.io 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
|
||||
|
||||
These are the environmental options available on meet backend.
|
||||
|
||||
| Option | Description | default |
|
||||
| ----------------------------------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| DATA_DIR | Data directory location | /data |
|
||||
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | Name of the database | meet |
|
||||
| DB_USER | User used to connect to database | dinum |
|
||||
| DB_PASSWORD | Password used to connect to the database | pass |
|
||||
| DB_HOST | Hostname of the database | localhost |
|
||||
| DB_PORT | Port to connect to database | 5432 |
|
||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
|
||||
| AWS_S3_REGION_NAME | S3 region | |
|
||||
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
|
||||
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
||||
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
|
||||
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
|
||||
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
|
||||
| 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_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_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_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| 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 |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_HOST | Host of 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_PORT | Port to connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||
| EMAIL_BRAND_NAME | Email branding name | |
|
||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||
| EMAIL_LOGO_IMG | Email logo image | |
|
||||
| EMAIL_DOMAIN | Email domain | |
|
||||
| EMAIL_APP_BASE_URL | Email app base URL | |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
|
||||
| SENTRY_DSN | Sentry server DSN | |
|
||||
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
|
||||
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
|
||||
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
|
||||
| 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_CLIENT_ID | OIDC client ID | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
|
||||
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
|
||||
| OIDC_RP_SCOPES | OIDC scopes | openid email |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
|
||||
| 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_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
|
||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
|
||||
| LOGIN_REDIRECT_URL | Login redirect URL | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
|
||||
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
|
||||
| LIVEKIT_API_KEY | LiveKit API key | |
|
||||
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
||||
| LIVEKIT_API_URL | LiveKit API URL | |
|
||||
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
||||
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
|
||||
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
|
||||
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
|
||||
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
|
||||
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
|
||||
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
|
||||
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
|
||||
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
|
||||
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
|
||||
| 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 |
|
||||
| Option | Description | default |
|
||||
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| DATA_DIR | Data directory location | /data |
|
||||
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | Name of the database | meet |
|
||||
| DB_USER | User used to connect to database | dinum |
|
||||
| DB_PASSWORD | Password used to connect to the database | pass |
|
||||
| DB_HOST | Hostname of the database | localhost |
|
||||
| DB_PORT | Port to connect to database | 5432 |
|
||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
|
||||
| AWS_S3_REGION_NAME | S3 region | |
|
||||
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
|
||||
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
||||
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
|
||||
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
|
||||
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
|
||||
| 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_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_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_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| 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 |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_HOST | Host of 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_PORT | Port to connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||
| EMAIL_BRAND_NAME | Email branding name | |
|
||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||
| EMAIL_LOGO_IMG | Email logo image | |
|
||||
| EMAIL_DOMAIN | Email domain | |
|
||||
| EMAIL_APP_BASE_URL | Email app base URL | |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
|
||||
| SENTRY_DSN | Sentry server DSN | |
|
||||
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
|
||||
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
|
||||
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
|
||||
| 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_CLIENT_ID | OIDC client ID | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
|
||||
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
|
||||
| OIDC_RP_SCOPES | OIDC scopes | openid email |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
|
||||
| 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_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
|
||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| LOGIN_REDIRECT_URL | Login redirect URL | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
|
||||
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
|
||||
| LIVEKIT_API_KEY | LiveKit API key | |
|
||||
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
||||
| LIVEKIT_API_URL | LiveKit API URL | |
|
||||
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
||||
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
|
||||
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
||||
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
|
||||
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
|
||||
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
|
||||
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
|
||||
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
|
||||
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
|
||||
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
|
||||
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
|
||||
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
|
||||
| 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 |
|
||||
|
||||
@@ -41,6 +41,19 @@ def get_frontend_configuration(request):
|
||||
"is_enabled": settings.RECORDING_ENABLE,
|
||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||
},
|
||||
"telephony": {
|
||||
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
||||
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
||||
if settings.ROOM_TELEPHONY_ENABLED
|
||||
else None,
|
||||
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||
},
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||
},
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
|
||||
@@ -107,8 +107,8 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "configuration", "access_level"]
|
||||
read_only_fields = ["id", "slug"]
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
|
||||
@@ -136,6 +136,13 @@ class NotificationService:
|
||||
"filename": recording.key,
|
||||
"email": owner_access.user.email,
|
||||
"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 = {
|
||||
|
||||
@@ -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,13 +1,25 @@
|
||||
"""LiveKit Events Service"""
|
||||
|
||||
# pylint: disable=E1101
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class LiveKitWebhookError(Exception):
|
||||
@@ -77,6 +89,8 @@ class LiveKitEventsService:
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
self.telephony_service = TelephonyService()
|
||||
self.recording_events = RecordingEventsService()
|
||||
|
||||
def receive(self, request):
|
||||
"""Process webhook and route to appropriate handler."""
|
||||
@@ -108,10 +122,77 @@ class LiveKitEventsService:
|
||||
# pylint: disable=not-callable
|
||||
handler(data)
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
"""Handle 'room_finished' event."""
|
||||
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):
|
||||
"""Handle 'room_started' event."""
|
||||
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Ignoring room event: room name '%s' is not a valid UUID format.",
|
||||
data.room.name,
|
||||
)
|
||||
raise ActionFailedError("Failed to process room started event") from e
|
||||
|
||||
try:
|
||||
room = models.Room.objects.get(id=room_id)
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
try:
|
||||
self.telephony_service.create_dispatch_rule(room)
|
||||
except TelephonyException as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to create telephony dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
"""Handle 'room_finished' event."""
|
||||
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Ignoring room event: room name '%s' is not a valid UUID format.",
|
||||
data.room.name,
|
||||
)
|
||||
raise ActionFailedError("Failed to process room finished event") from e
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
try:
|
||||
self.telephony_service.delete_dispatch_rule(room_id)
|
||||
except TelephonyException as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to delete telephony dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
self.lobby_service.clear_room_cache(room_id)
|
||||
except Exception as e:
|
||||
raise ActionFailedError("Failed to process room finished event") from e
|
||||
raise ActionFailedError(
|
||||
f"Failed to clear room cache for room {room_id}"
|
||||
) from e
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Lobby Service"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
@@ -11,13 +10,6 @@ from uuid import UUID
|
||||
from django.conf import settings
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,10 +38,6 @@ class LobbyParticipantNotFound(LobbyError):
|
||||
"""Raised when participant is not found."""
|
||||
|
||||
|
||||
class LobbyNotificationError(LobbyError):
|
||||
"""Raised when LiveKit notification fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LobbyParticipant:
|
||||
"""Participant in a lobby system."""
|
||||
@@ -211,9 +199,6 @@ class LobbyService:
|
||||
|
||||
Create a new participant entry in waiting status and notify room
|
||||
participants of the new entry request.
|
||||
|
||||
Raises:
|
||||
LobbyNotificationError: If room notification fails
|
||||
"""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
@@ -226,10 +211,15 @@ class LobbyService:
|
||||
)
|
||||
|
||||
try:
|
||||
self.notify_participants(room_id=room_id)
|
||||
except LobbyNotificationError:
|
||||
utils.notify_participants(
|
||||
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
|
||||
pass
|
||||
logger.exception("Failed to notify room participants")
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.set(
|
||||
@@ -334,44 +324,6 @@ class LobbyService:
|
||||
participant.status = status
|
||||
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:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Telephony service for managing SIP dispatch rules for room access."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import TwirpError
|
||||
from livekit.protocol.sip import (
|
||||
CreateSIPDispatchRuleRequest,
|
||||
DeleteSIPDispatchRuleRequest,
|
||||
ListSIPDispatchRuleRequest,
|
||||
SIPDispatchRule,
|
||||
SIPDispatchRuleDirect,
|
||||
)
|
||||
|
||||
from core import utils
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class TelephonyException(Exception):
|
||||
"""Exception raised when telephony operations fail."""
|
||||
|
||||
|
||||
class TelephonyService:
|
||||
"""Service for managing participant access through the telephony system (SIP)."""
|
||||
|
||||
def _rule_name(self, room_id):
|
||||
"""Generate the rule name for a room based on its ID."""
|
||||
return f"SIP_{str(room_id)}"
|
||||
|
||||
@async_to_sync
|
||||
async def create_dispatch_rule(self, room):
|
||||
"""Create a SIP inbound dispatch rule for direct room routing.
|
||||
|
||||
Configures telephony to route incoming SIP calls directly to the specified room
|
||||
using the room's ID and PIN code for authentication.
|
||||
"""
|
||||
|
||||
direct_rule = SIPDispatchRule(
|
||||
dispatch_rule_direct=SIPDispatchRuleDirect(
|
||||
room_name=str(room.id), pin=str(room.pin_code)
|
||||
)
|
||||
)
|
||||
|
||||
request = CreateSIPDispatchRuleRequest(
|
||||
rule=direct_rule, name=self._rule_name(room.id)
|
||||
)
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
||||
except TwirpError as e:
|
||||
logger.exception(
|
||||
"Unexpected error creating dispatch rule for room %s", room.id
|
||||
)
|
||||
raise TelephonyException("Could not create dispatch rule") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
async def _list_dispatch_rules_ids(self, room_id):
|
||||
"""List SIP dispatch rule IDs for a specific room.
|
||||
|
||||
Fetches all existing SIP dispatch rules and filters them by room name
|
||||
since LiveKit API doesn't support server-side filtering by 'room_name'.
|
||||
This approach is acceptable for moderate scale but may need refactoring
|
||||
for high-volume scenarios.
|
||||
|
||||
Note:
|
||||
Feature request for server-side filtering: livekit/sip#405
|
||||
"""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
existing_rules = await lkapi.sip.list_sip_dispatch_rule(
|
||||
list=ListSIPDispatchRuleRequest()
|
||||
)
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
||||
raise TelephonyException("Could not list dispatch rules") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
if not existing_rules or not existing_rules.items:
|
||||
return []
|
||||
|
||||
rule_name = self._rule_name(room_id)
|
||||
|
||||
return [
|
||||
existing_rule.sip_dispatch_rule_id
|
||||
for existing_rule in existing_rules.items
|
||||
if existing_rule.name == rule_name
|
||||
]
|
||||
|
||||
@async_to_sync
|
||||
async def delete_dispatch_rule(self, room_id):
|
||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||
|
||||
rules_ids = await self._list_dispatch_rules_ids(room_id)
|
||||
|
||||
if not rules_ids:
|
||||
logger.info("No dispatch rules found for room %s", room_id)
|
||||
return False
|
||||
|
||||
if len(rules_ids) > 1:
|
||||
logger.error("Multiple dispatch rules found for room %s", room_id)
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
try:
|
||||
for rule_id in rules_ids:
|
||||
await lkapi.sip.delete_sip_dispatch_rule(
|
||||
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
||||
raise TelephonyException("Could not delete dispatch rules") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -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
|
||||
|
||||
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"),
|
||||
):
|
||||
response = client.post(
|
||||
@@ -86,7 +86,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
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"),
|
||||
):
|
||||
response = client.post(
|
||||
@@ -156,7 +156,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
|
||||
# Mock external service calls to isolate the test
|
||||
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"),
|
||||
):
|
||||
# Make request as a new anonymous user
|
||||
@@ -205,7 +205,7 @@ def test_request_entry_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
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
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService,
|
||||
"_get_or_create_participant_id",
|
||||
@@ -315,7 +315,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"})
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
),
|
||||
|
||||
@@ -32,6 +32,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -85,6 +88,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -101,6 +105,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -209,6 +214,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -251,6 +257,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -295,6 +302,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -324,6 +332,7 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -372,6 +381,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -458,6 +468,7 @@ def test_api_rooms_retrieve_administrators(
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ def test_handled_event_type(
|
||||
|
||||
def test_unhandled_event_type(client, mock_livekit_config):
|
||||
"""Should return 200 for event types that have no handler."""
|
||||
event_data = json.dumps({"event": "room_started"})
|
||||
event_data = json.dumps({"event": "participant_joined"})
|
||||
|
||||
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
|
||||
token = api.AccessToken(
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""
|
||||
Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
from core.factories import RecordingFactory, RoomFactory
|
||||
from core.recording.services.recording_events import RecordingEventsService
|
||||
from core.services.livekit_events import (
|
||||
ActionFailedError,
|
||||
AuthenticationError,
|
||||
@@ -17,6 +20,8 @@ from core.services.livekit_events import (
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -53,45 +58,253 @@ def test_initialization(
|
||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||
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")
|
||||
def test_handle_room_finished(mock_clear_cache, service):
|
||||
"""Should clear lobby cache when room is finished."""
|
||||
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_room_name = uuid.uuid4()
|
||||
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
|
||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_skips_telephony_when_disabled(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
mock_room_name = uuid.uuid4()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_delete_dispatch_rule.assert_not_called()
|
||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
||||
)
|
||||
def test_handle_room_finished_error(mock_clear_cache, service):
|
||||
"""Should raise ActionFailedError when processing fails."""
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should raise ActionFailedError when lobby cache clearing fails when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||
with pytest.raises(
|
||||
ActionFailedError, match="Failed to process room finished event"
|
||||
):
|
||||
|
||||
expected_error = (
|
||||
"Failed to clear room cache for room 00000000-0000-0000-0000-000000000000"
|
||||
)
|
||||
|
||||
with pytest.raises(ActionFailedError, match=expected_error):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_delete_dispatch_rule.assert_called_once_with(
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000000")
|
||||
)
|
||||
|
||||
def test_handle_room_finished_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when processing fails."""
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(
|
||||
TelephonyService,
|
||||
"delete_dispatch_rule",
|
||||
side_effect=TelephonyException("Test error"),
|
||||
)
|
||||
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should raise ActionFailedError when dispatch rule deletion fails when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
expected_error = (
|
||||
"Failed to delete telephony dispatch rule for room "
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
)
|
||||
|
||||
with pytest.raises(ActionFailedError, match=expected_error):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_clear_cache.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "invalid"
|
||||
|
||||
with pytest.raises(
|
||||
ActionFailedError, match="Failed to process room finished event"
|
||||
):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||
mock_create_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should create telephony dispatch rule when room starts successfully."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_create_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||
mock_create_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_create_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when room name format is invalid when room starts."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "invalid"
|
||||
|
||||
with pytest.raises(ActionFailedError, match="Failed to process room started event"):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
||||
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(uuid.uuid4())
|
||||
|
||||
expected_error = f"Room with ID {mock_data.room.name} does not exist"
|
||||
|
||||
with pytest.raises(ActionFailedError, match=expected_error):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ Test lobby service.
|
||||
# pylint: disable=W0621,W0613, W0212, R0913
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
@@ -14,18 +13,17 @@ from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
LobbyNotificationError,
|
||||
LobbyParticipant,
|
||||
LobbyParticipantNotFound,
|
||||
LobbyParticipantParsingError,
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -414,7 +412,7 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_enter_success(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
@@ -443,13 +441,15 @@ def test_enter_success(
|
||||
participant.to_dict(),
|
||||
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
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@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(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
@@ -460,7 +460,7 @@ def test_enter_with_notification_error(
|
||||
):
|
||||
"""Test participant entry with notification error."""
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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):
|
||||
"""Test clearing room cache actually removes entries from cache."""
|
||||
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Test telephony service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import TwirpError
|
||||
from livekit.protocol.sip import (
|
||||
CreateSIPDispatchRuleRequest,
|
||||
DeleteSIPDispatchRuleRequest,
|
||||
ListSIPDispatchRuleRequest,
|
||||
ListSIPDispatchRuleResponse,
|
||||
SIPDispatchRule,
|
||||
SIPDispatchRuleInfo,
|
||||
)
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def create_mock_livekit_client():
|
||||
"""Factory for creating LiveKit client mock."""
|
||||
mock_api = mock.Mock()
|
||||
mock_api.sip = mock.Mock()
|
||||
mock_api.aclose = mock.AsyncMock()
|
||||
return mock_api
|
||||
|
||||
|
||||
def test_rule_name():
|
||||
"""Test rule name generation."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
rule_name = telephony_service._rule_name(room.id)
|
||||
|
||||
assert rule_name == f"SIP_{str(room.id)}"
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_success(mock_client_factory):
|
||||
"""Test successful dispatch rule creation."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
||||
|
||||
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
|
||||
assert create_request.name == f"SIP_{str(room.id)}"
|
||||
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
|
||||
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
"""Test dispatch rule creation when API fails."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
"""Test successful listing of dispatch rule IDs."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-1",
|
||||
name=f"SIP_{str(room.id)}",
|
||||
rule=SIPDispatchRule(),
|
||||
),
|
||||
SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-2", name="OTHER_RULE", rule=SIPDispatchRule()
|
||||
),
|
||||
SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-3",
|
||||
name=f"SIP_{str(room.id)}",
|
||||
rule=SIPDispatchRule(),
|
||||
),
|
||||
]
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "rule-1" in result
|
||||
assert "rule-3" in result
|
||||
assert "rule-2" not in result
|
||||
|
||||
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
||||
list_request = mock_api.sip.list_sip_dispatch_rule.call_args[1]["list"]
|
||||
assert isinstance(list_request, ListSIPDispatchRuleRequest)
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when no rules exist."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when no rules match the room."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-1", name="OTHER_RULE_1", rule=SIPDispatchRule()
|
||||
),
|
||||
SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-2", name="OTHER_RULE_2", rule=SIPDispatchRule()
|
||||
),
|
||||
]
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when API fails."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
|
||||
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting dispatch rules when no rules exist."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = []
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is False
|
||||
mock_list_rules.assert_called_once_with(room.id)
|
||||
mock_client_factory.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting a single dispatch rule."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||
delete_request = mock_api.sip.delete_sip_dispatch_rule.call_args[1]["delete"]
|
||||
assert isinstance(delete_request, DeleteSIPDispatchRuleRequest)
|
||||
assert delete_request.sip_dispatch_rule_id == "rule-1"
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting multiple dispatch rules."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
||||
|
||||
deleted_rule_ids = [
|
||||
call_args[1]["delete"].sip_dispatch_rule_id
|
||||
for call_args in mock_api.sip.delete_sip_dispatch_rule.call_args_list
|
||||
]
|
||||
assert all(
|
||||
rule_id in deleted_rule_ids for rule_id in ["rule-1", "rule-2", "rule-3"]
|
||||
)
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting multiple dispatch rules when one deletion fails."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||
mock_api = create_mock_livekit_client()
|
||||
|
||||
call_count = 0
|
||||
|
||||
def delete_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
if call_count == 0:
|
||||
call_count += 1
|
||||
return None
|
||||
raise TwirpError(msg="Deletion failed", code=500, status=500)
|
||||
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=delete_side_effect
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting dispatch rules when API fails immediately."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -2,9 +2,13 @@
|
||||
Test utils functions
|
||||
"""
|
||||
|
||||
import json
|
||||
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")
|
||||
@@ -60,3 +64,105 @@ def test_create_livekit_client_custom_configuration(
|
||||
create_livekit_client(custom_configuration)
|
||||
|
||||
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()
|
||||
|
||||
@@ -15,7 +15,15 @@ from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
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:
|
||||
@@ -158,3 +166,37 @@ def create_livekit_client(custom_configuration=None):
|
||||
configuration = custom_configuration or settings.LIVEKIT_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.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\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"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,15 +29,15 @@ msgstr "Berechtigungen"
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: core/admin.py:143
|
||||
#: core/admin.py:147
|
||||
msgid "No owner"
|
||||
msgstr "Kein Eigentümer"
|
||||
|
||||
#: core/admin.py:146
|
||||
#: core/admin.py:150
|
||||
msgid "Multiple owners"
|
||||
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."
|
||||
msgstr ""
|
||||
"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
|
||||
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
|
||||
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"
|
||||
|
||||
#: 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/screen_recording.txt:22
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#, python-format
|
||||
msgid " Thank you for using %(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. "
|
||||
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: 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:"
|
||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#, python-format
|
||||
msgid ""
|
||||
" 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 "
|
||||
"an unser Support-Team unter %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
#: meet/settings.py:163
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:164
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:164
|
||||
#: meet/settings.py:165
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:165
|
||||
#: meet/settings.py:166
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-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"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,15 +29,15 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Important dates"
|
||||
|
||||
#: core/admin.py:143
|
||||
#: core/admin.py:147
|
||||
msgid "No owner"
|
||||
msgstr "No owner"
|
||||
|
||||
#: core/admin.py:146
|
||||
#: core/admin.py:150
|
||||
msgid "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."
|
||||
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"
|
||||
|
||||
#: 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/screen_recording.txt:22
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#, python-format
|
||||
msgid " 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. "
|
||||
msgstr " The recording will expire in %(days)s days. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: 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:"
|
||||
msgstr "To keep this recording permanently:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Click the \"Open\" button below "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Use the \"Download\" button in the interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Save the file to your preferred location"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#, python-format
|
||||
msgid ""
|
||||
" 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 "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
#: meet/settings.py:163
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:164
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:164
|
||||
#: meet/settings.py:165
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:165
|
||||
#: meet/settings.py:166
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-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"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,15 +29,15 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/admin.py:143
|
||||
#: core/admin.py:147
|
||||
msgid "No owner"
|
||||
msgstr "Pas de propriétaire"
|
||||
|
||||
#: core/admin.py:146
|
||||
#: core/admin.py:150
|
||||
msgid "Multiple owners"
|
||||
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."
|
||||
msgstr ""
|
||||
"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 @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, "
|
||||
"des chiffres et les caractères @/./+/-/_."
|
||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||
"chiffres et les caractères @/./+/-/_."
|
||||
|
||||
#: core/models.py:144
|
||||
msgid "sub"
|
||||
@@ -252,7 +252,8 @@ msgstr "Code PIN de la salle"
|
||||
|
||||
#: core/models.py:392
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||
msgstr ""
|
||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||
|
||||
#: core/models.py:398 core/models.py:552
|
||||
msgid "Room"
|
||||
@@ -271,9 +272,8 @@ msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant "
|
||||
"est conservé même lorsque le Worker s'arrête, permettant un suivi "
|
||||
"facile."
|
||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||
|
||||
#: core/models.py:573
|
||||
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"
|
||||
|
||||
#: 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/screen_recording.txt:22
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Merci d'utiliser %(brandname)s. "
|
||||
@@ -395,33 +395,41 @@ msgstr ""
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " L'enregistrement expirera dans %(days)s jours. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: 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:"
|
||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#, python-format
|
||||
msgid ""
|
||||
" 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 "
|
||||
"équipe d'assistance à %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
#: meet/settings.py:163
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:164
|
||||
msgid "French"
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:164
|
||||
#: meet/settings.py:165
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
#: meet/settings.py:165
|
||||
#: meet/settings.py:166
|
||||
msgid "German"
|
||||
msgstr "Allemand"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-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"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,15 +29,15 @@ msgstr "Rechten"
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: core/admin.py:143
|
||||
#: core/admin.py:147
|
||||
msgid "No owner"
|
||||
msgstr "Geen eigenaar"
|
||||
|
||||
#: core/admin.py:146
|
||||
#: core/admin.py:150
|
||||
msgid "Multiple owners"
|
||||
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."
|
||||
msgstr ""
|
||||
"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
|
||||
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
|
||||
msgid "Room"
|
||||
@@ -361,9 +362,9 @@ msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: 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
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Bedankt voor het gebruik van %(brandname)s. "
|
||||
@@ -389,33 +390,41 @@ msgstr ""
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " De opname verloopt over %(days)s dagen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: 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:"
|
||||
msgstr "Om deze opname permanent te bewaren:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klik op de \"Openen\"-knop hieronder "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Gebruik de \"Download\"-knop in de interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Sla het bestand op naar je gewenste locatie"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
msgid "Open"
|
||||
msgstr "Openen"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#, python-format
|
||||
msgid ""
|
||||
" 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 "
|
||||
"team via %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:162
|
||||
#: meet/settings.py:163
|
||||
msgid "English"
|
||||
msgstr "Engels"
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:164
|
||||
msgid "French"
|
||||
msgstr "Frans"
|
||||
|
||||
#: meet/settings.py:164
|
||||
#: meet/settings.py:165
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
|
||||
#: meet/settings.py:165
|
||||
#: meet/settings.py:166
|
||||
msgid "German"
|
||||
msgstr "Duits"
|
||||
|
||||
@@ -11,11 +11,12 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
"""
|
||||
|
||||
import json
|
||||
from os import path
|
||||
from os import environ, path
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import dj_database_url
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from lasuite.configuration.values import SecretFileValue
|
||||
@@ -86,7 +87,9 @@ class Base(Configuration):
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"default": dj_database_url.config()
|
||||
if environ.get("DATABASE_URL")
|
||||
else {
|
||||
"ENGINE": values.Value(
|
||||
"django.db.backends.postgresql_psycopg2",
|
||||
environ_name="DB_ENGINE",
|
||||
@@ -430,6 +433,17 @@ class Base(Configuration):
|
||||
OIDC_RP_SCOPES = values.Value(
|
||||
"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(
|
||||
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
|
||||
)
|
||||
@@ -481,6 +495,14 @@ class Base(Configuration):
|
||||
),
|
||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||
}
|
||||
LIVEKIT_FORCE_WSS_PROTOCOL = values.BooleanValue(
|
||||
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
|
||||
)
|
||||
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
|
||||
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
|
||||
environ_prefix=None,
|
||||
default=False,
|
||||
)
|
||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||
)
|
||||
@@ -525,6 +547,11 @@ class Base(Configuration):
|
||||
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
|
||||
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(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
@@ -609,6 +636,16 @@ class Base(Configuration):
|
||||
environ_name="ROOM_TELEPHONY_PIN_MAX_RETRIES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ROOM_TELEPHONY_PHONE_NUMBER = values.Value(
|
||||
None,
|
||||
environ_name="ROOM_TELEPHONY_PHONE_NUMBER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ROOM_TELEPHONY_DEFAULT_COUNTRY = values.Value(
|
||||
"US",
|
||||
environ_name="ROOM_TELEPHONY_DEFAULT_COUNTRY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.27"
|
||||
version = "0.1.33"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -29,6 +29,7 @@ dependencies = [
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.5.3",
|
||||
"dj-database-url==2.3.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
"""Setup file for the impress module. All configuration stands in the setup.cfg file."""
|
||||
# coding: utf-8
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
Generated
+1463
-1355
File diff suppressed because it is too large
Load Diff
+24
-21
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.33",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,49 +13,52 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.9.12",
|
||||
"@livekit/components-react": "2.9.13",
|
||||
"@livekit/components-styles": "1.1.6",
|
||||
"@livekit/track-processors": "0.5.7",
|
||||
"@pandacss/preset-panda": "0.53.6",
|
||||
"@react-aria/toast": "3.0.2",
|
||||
"@pandacss/preset-panda": "0.54.0",
|
||||
"@react-aria/toast": "3.0.5",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.76.0",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "25.1.2",
|
||||
"i18next-browser-languagedetector": "8.1.0",
|
||||
"humanize-duration": "3.33.0",
|
||||
"i18next": "25.3.1",
|
||||
"i18next-browser-languagedetector": "8.2.0",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.13.8",
|
||||
"posthog-js": "1.240.6",
|
||||
"libphonenumber-js": "1.12.10",
|
||||
"livekit-client": "2.15.2",
|
||||
"posthog-js": "1.256.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.8.0",
|
||||
"react-aria-components": "1.10.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.1.5",
|
||||
"wouter": "3.7.0"
|
||||
"wouter": "3.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.53.6",
|
||||
"@tanstack/eslint-plugin-query": "5.74.7",
|
||||
"@tanstack/react-query-devtools": "5.76.0",
|
||||
"@types/node": "22.15.17",
|
||||
"@pandacss/dev": "0.54.0",
|
||||
"@tanstack/eslint-plugin-query": "5.81.2",
|
||||
"@tanstack/react-query-devtools": "5.81.5",
|
||||
"@types/humanize-duration": "3.27.4",
|
||||
"@types/node": "22.16.0",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||
"@typescript-eslint/parser": "8.32.0",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.35.1",
|
||||
"@typescript-eslint/parser": "8.35.1",
|
||||
"@vitejs/plugin-react": "4.6.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "10.1.5",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "6.2.7",
|
||||
"vite": "7.0.2",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,19 @@ export interface ApiConfig {
|
||||
is_enabled?: boolean
|
||||
available_modes?: RecordingMode[]
|
||||
expiration_days?: number
|
||||
max_duration?: number
|
||||
}
|
||||
telephony: {
|
||||
enabled: boolean
|
||||
phone_number?: string
|
||||
default_country?: string
|
||||
}
|
||||
manifest_link?: string
|
||||
livekit: {
|
||||
url: string
|
||||
force_wss_protocol: boolean
|
||||
enable_firefox_proxy_workaround: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
|
||||
@@ -1,100 +1,187 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
|
||||
import { Bold, Button, Dialog, type DialogProps, P, Text } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ApiAccessLevel, ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
|
||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
|
||||
|
||||
// fixme - duplication with the InviteDialog
|
||||
export const LaterMeetingDialog = ({
|
||||
roomId,
|
||||
room,
|
||||
...dialogProps
|
||||
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('home')
|
||||
const roomUrl = getRouteUrl('room', roomId)
|
||||
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [isCopied])
|
||||
const roomUrl = room && getRouteUrl('room', room?.slug)
|
||||
const telephony = useTelephony()
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const isTelephonyReadyForUse = useMemo(() => {
|
||||
return telephony?.enabled && room?.pin_code
|
||||
}, [telephony?.enabled, room?.pin_code])
|
||||
|
||||
const {
|
||||
isCopied,
|
||||
copyRoomToClipboard,
|
||||
isRoomUrlCopied,
|
||||
copyRoomUrlToClipboard,
|
||||
} = useCopyRoomToClipboard(room || undefined)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={!!roomId}
|
||||
{...dialogProps}
|
||||
title={t('laterMeetingDialog.heading')}
|
||||
>
|
||||
<P>{t('laterMeetingDialog.description')}</P>
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'primary'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('laterMeetingDialog.copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
setIsCopied(true)
|
||||
}}
|
||||
onHoverChange={setIsHovered}
|
||||
data-attr="later-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
{t('laterMeetingDialog.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||
/>
|
||||
{isHovered ? (
|
||||
t('laterMeetingDialog.copy')
|
||||
) : (
|
||||
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
|
||||
<P>{t('description')}</P>
|
||||
{!!roomUrl && (
|
||||
<>
|
||||
{isTelephonyReadyForUse ? (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
backgroundColor: 'gray.50',
|
||||
borderRadius: '0.75rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '1.75rem 1.5rem',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
textWrap: 'nowrap',
|
||||
}}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
<Text as="p" wrap="pretty">
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && (
|
||||
<Button
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={t('copyUrl')}
|
||||
tooltip={t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<HStack>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primary.200',
|
||||
borderRadius: '50%',
|
||||
padding: '4px',
|
||||
marginTop: '1rem',
|
||||
})}
|
||||
>
|
||||
<RiSpam2Fill
|
||||
size={22}
|
||||
className={css({
|
||||
fill: 'primary.500',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
{t('laterMeetingDialog.permissions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||
{telephony?.internationalPhoneNumber}
|
||||
</Text>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||
{formatPinCode(room?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="later-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||
/>
|
||||
{t('copy')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'primary'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={copyRoomToClipboard}
|
||||
onHoverChange={setIsHovered}
|
||||
data-attr="later-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
size={18}
|
||||
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||
/>
|
||||
{isHovered ? (
|
||||
t('copy')
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'none',
|
||||
textWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{room?.access_level == ApiAccessLevel.PUBLIC && (
|
||||
<HStack>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primary.200',
|
||||
borderRadius: '50%',
|
||||
padding: '4px',
|
||||
marginTop: '1rem',
|
||||
})}
|
||||
>
|
||||
<RiSpam2Fill
|
||||
size={22}
|
||||
className={css({
|
||||
fill: 'primary.500',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
{t('permissions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -153,7 +154,7 @@ export const Home = () => {
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
||||
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
@@ -202,7 +203,7 @@ export const Home = () => {
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoomId(data.slug)
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
@@ -251,8 +252,8 @@ export const Home = () => {
|
||||
</RightColumn>
|
||||
</Columns>
|
||||
<LaterMeetingDialog
|
||||
roomId={laterRoomId ?? ''}
|
||||
onOpenChange={() => setLaterRoomId(null)}
|
||||
room={laterRoom}
|
||||
onOpenChange={() => setLaterRoom(null)}
|
||||
/>
|
||||
</Screen>
|
||||
</UserAware>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
ANIMATION_DURATION,
|
||||
ReactionPortals,
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
@@ -72,29 +71,33 @@ export const MainNotificationToast = () => {
|
||||
) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
|
||||
if (!participant || !notification) return
|
||||
if (!notification) return
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.ParticipantMuted:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.ParticipantMuted,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
)
|
||||
if (participant) {
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.ParticipantMuted,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
)
|
||||
}
|
||||
|
||||
break
|
||||
case NotificationType.ReactionReceived:
|
||||
if (notification.data?.emoji)
|
||||
if (notification.data?.emoji && participant)
|
||||
handleEmoji(notification.data.emoji, participant)
|
||||
break
|
||||
case NotificationType.TranscriptionStarted:
|
||||
case NotificationType.TranscriptionStopped:
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: notification.type,
|
||||
},
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
@@ -151,17 +154,14 @@ export const MainNotificationToast = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationReceived = (
|
||||
prevMetadataStr: string | undefined,
|
||||
changedAttributes: Record<string, string>,
|
||||
participant: Participant
|
||||
) => {
|
||||
if (!participant) return
|
||||
if (isMobileBrowser()) return
|
||||
if (participant.isLocal) return
|
||||
|
||||
const prevMetadata = safeParseMetadata(prevMetadataStr)
|
||||
const metadata = safeParseMetadata(participant.metadata)
|
||||
|
||||
if (prevMetadata?.raised == metadata?.raised) return
|
||||
if (!('handRaisedAt' in changedAttributes)) return
|
||||
|
||||
const existingToast = toastQueue.visibleToasts.find(
|
||||
(toast) =>
|
||||
@@ -169,12 +169,12 @@ export const MainNotificationToast = () => {
|
||||
toast.content.type === NotificationType.HandRaised
|
||||
)
|
||||
|
||||
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
||||
if (existingToast && !changedAttributes?.handRaisedAt) {
|
||||
toastQueue.close(existingToast.key)
|
||||
return
|
||||
}
|
||||
|
||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
||||
if (!existingToast && !!changedAttributes?.handRaisedAt) {
|
||||
triggerNotificationSound(NotificationType.HandRaised)
|
||||
toastQueue.add(
|
||||
{
|
||||
@@ -186,10 +186,13 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||
room.on(RoomEvent.ParticipantAttributesChanged, handleNotificationReceived)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
||||
room.off(
|
||||
RoomEvent.ParticipantAttributesChanged,
|
||||
handleNotificationReceived
|
||||
)
|
||||
}
|
||||
}, [room, triggerNotificationSound])
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ export enum NotificationType {
|
||||
ParticipantWaiting = 'participantWaiting',
|
||||
TranscriptionStarted = 'transcriptionStarted',
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
TranscriptionLimitReached = 'transcriptionLimitReached',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
}
|
||||
|
||||
@@ -19,10 +19,14 @@ export function ToastAnyRecording({ state, ...props }: ToastProps) {
|
||||
return 'transcript.started'
|
||||
case NotificationType.TranscriptionStopped:
|
||||
return 'transcript.stopped'
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
return 'transcript.limitReached'
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
return 'screenRecording.started'
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return 'screenRecording.stopped'
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
return 'screenRecording.limitReached'
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -40,7 +44,7 @@ export function ToastAnyRecording({ state, ...props }: ToastProps) {
|
||||
gap={0}
|
||||
>
|
||||
{t(key, {
|
||||
name: participant.name,
|
||||
name: participant?.name,
|
||||
})}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
|
||||
@@ -29,6 +29,9 @@ export function ToastJoined({ state, ...props }: ToastProps) {
|
||||
)
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
const participant = props.toast.content.participant
|
||||
|
||||
if (!participant) return
|
||||
|
||||
const trackReference = {
|
||||
participant,
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ToastMessageReceived({ state, ...props }: ToastProps) {
|
||||
}
|
||||
}, [isChatOpen, toast, state])
|
||||
|
||||
if (isChatOpen) return null
|
||||
if (isChatOpen || !participant) return null
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
|
||||
@@ -10,6 +10,9 @@ export function ToastMuted({ state, ...props }: ToastProps) {
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const participant = props.toast.content.participant
|
||||
|
||||
if (!participant) return
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Participant } from 'livekit-client'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
|
||||
export interface ToastData {
|
||||
participant: Participant
|
||||
participant?: Participant
|
||||
type: NotificationType
|
||||
message?: string
|
||||
// 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 { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
|
||||
if (!participant) return
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
|
||||
@@ -40,8 +40,10 @@ const renderToast = (
|
||||
|
||||
case NotificationType.TranscriptionStarted:
|
||||
case NotificationType.TranscriptionStopped:
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
@@ -18,14 +18,18 @@ import {
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
||||
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'recordingStateToast',
|
||||
})
|
||||
const room = useRoomContext()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
@@ -53,13 +57,10 @@ export const RecordingStateToast = () => {
|
||||
}, [room.isRecording])
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (
|
||||
payload: Uint8Array,
|
||||
participant?: RemoteParticipant
|
||||
) => {
|
||||
const handleDataReceived = (payload: Uint8Array) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
|
||||
if (!participant || !notification) return
|
||||
if (!notification) return
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.TranscriptionStarted:
|
||||
@@ -68,12 +69,20 @@ export const RecordingStateToast = () => {
|
||||
case NotificationType.TranscriptionStopped:
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
break
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
if (isAdminOrOwner) setIsAlertOpen(true)
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
break
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
if (isAdminOrOwner) setIsAlertOpen(true)
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -100,7 +109,7 @@ export const RecordingStateToast = () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room, recordingSnap])
|
||||
}, [room, recordingSnap, setIsAlertOpen, isAdminOrOwner])
|
||||
|
||||
const key = useMemo(() => {
|
||||
switch (recordingSnap.status) {
|
||||
@@ -119,7 +128,14 @@ export const RecordingStateToast = () => {
|
||||
}
|
||||
}, [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')
|
||||
|
||||
|
||||
@@ -14,6 +14,28 @@ import { fetchRecording } from '../api/fetchRecording'
|
||||
import { RecordingStatus } from '@/features/recording'
|
||||
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 = () => {
|
||||
const { t } = useTranslation('recording')
|
||||
const { data: configData } = useConfig()
|
||||
@@ -27,11 +49,11 @@ export const RecordingDownload = () => {
|
||||
enabled: !!recordingId,
|
||||
})
|
||||
|
||||
if (isLoading || !data || isAuthLoading) {
|
||||
if (isLoggedIn === undefined || isAuthLoading) {
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (isLoggedIn === false && !isAuthLoading) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
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')} />
|
||||
}
|
||||
|
||||
@@ -103,6 +129,28 @@ export const RecordingDownload = () => {
|
||||
>
|
||||
{t('success.button')}
|
||||
</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>
|
||||
</Center>
|
||||
</Screen>
|
||||
|
||||
@@ -14,6 +14,7 @@ export type ApiRoom = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
pin_code: string
|
||||
is_administrable: boolean
|
||||
access_level: ApiAccessLevel
|
||||
livekit?: ApiLiveKit
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LiveKitRoom } from '@livekit/components-react'
|
||||
import { Room, RoomOptions } from 'livekit-client'
|
||||
import {
|
||||
LiveKitRoom,
|
||||
usePersistentUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import {
|
||||
DisconnectReason,
|
||||
MediaDeviceFailure,
|
||||
Room,
|
||||
RoomOptions,
|
||||
supportsAdaptiveStream,
|
||||
supportsDynacast,
|
||||
} from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
@@ -13,27 +23,38 @@ import { ApiRoom } from '../api/ApiRoom'
|
||||
import { useCreateRoom } from '../api/createRoom'
|
||||
import { InviteDialog } from './InviteDialog'
|
||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import posthog from 'posthog-js'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
userConfig,
|
||||
initialRoomData,
|
||||
mode = 'join',
|
||||
}: {
|
||||
roomId: string
|
||||
userConfig: LocalUserChoices
|
||||
mode?: 'join' | 'create'
|
||||
initialRoomData?: ApiRoom
|
||||
}) => {
|
||||
const posthog = usePostHog()
|
||||
const { data: apiConfig } = useConfig()
|
||||
|
||||
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||
userChoices: LocalUserChoices
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
}, [roomId, posthog])
|
||||
const fetchKey = [keys.room, roomId]
|
||||
|
||||
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||
|
||||
const {
|
||||
mutateAsync: createRoom,
|
||||
status: createStatus,
|
||||
@@ -65,10 +86,13 @@ export const Conference = ({
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isAdaptiveStreamSupported = supportsAdaptiveStream()
|
||||
const isDynacastSupported = supportsDynacast()
|
||||
|
||||
const roomOptions = useMemo((): RoomOptions => {
|
||||
return {
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
adaptiveStream: isAdaptiveStreamSupported,
|
||||
dynacast: isDynacastSupported,
|
||||
publishDefaults: {
|
||||
videoCodec: 'vp9',
|
||||
},
|
||||
@@ -80,11 +104,82 @@ export const Conference = ({
|
||||
},
|
||||
}
|
||||
// do not rely on the userConfig object directly as its reference may change on every render
|
||||
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
||||
}, [
|
||||
userConfig.videoDeviceId,
|
||||
userConfig.audioDeviceId,
|
||||
isAdaptiveStreamSupported,
|
||||
isDynacastSupported,
|
||||
])
|
||||
|
||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Warm up connection to LiveKit server before joining room
|
||||
* This prefetch helps reduce initial connection latency by establishing
|
||||
* an early HTTP connection to the WebRTC signaling server
|
||||
*
|
||||
* It should cache DNS and TLS keys.
|
||||
*/
|
||||
const prepareConnection = async () => {
|
||||
if (!apiConfig || isConnectionWarmedUp) return
|
||||
await room.prepareConnection(apiConfig.livekit.url)
|
||||
|
||||
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||
try {
|
||||
const wssUrl =
|
||||
apiConfig.livekit.url
|
||||
.replace('https://', 'wss://')
|
||||
.replace(/\/$/, '') + '/rtc'
|
||||
|
||||
/**
|
||||
* FIREFOX + PROXY WORKAROUND:
|
||||
*
|
||||
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
||||
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
||||
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
|
||||
*
|
||||
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
||||
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
||||
*
|
||||
* Note: This issue is reproducible on LiveKit's demo app.
|
||||
* Reference: livekit-examples/meet/issues/466
|
||||
*/
|
||||
const ws = new WebSocket(wssUrl)
|
||||
// 401 unauthorized response is expected
|
||||
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||
} catch (e) {
|
||||
console.debug('Firefox WebSocket workaround failed.', e)
|
||||
}
|
||||
}
|
||||
|
||||
setIsConnectionWarmedUp(true)
|
||||
}
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
error: MediaDeviceFailure | null
|
||||
kind: MediaDeviceKind | null
|
||||
}>({
|
||||
error: null,
|
||||
kind: null,
|
||||
})
|
||||
|
||||
/*
|
||||
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
||||
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
|
||||
*/
|
||||
const serverUrl = useMemo(() => {
|
||||
const livekit_url = apiConfig?.livekit.url
|
||||
if (!livekit_url) return
|
||||
if (apiConfig?.livekit.force_wss_protocol) {
|
||||
return livekit_url.replace('https://', 'wss://')
|
||||
}
|
||||
return livekit_url
|
||||
}, [apiConfig?.livekit])
|
||||
|
||||
const { t } = useTranslation('rooms')
|
||||
if (isCreateError) {
|
||||
@@ -109,9 +204,9 @@ export const Conference = ({
|
||||
<Screen header={false} footer={false}>
|
||||
<LiveKitRoom
|
||||
room={room}
|
||||
serverUrl={data?.livekit?.url}
|
||||
serverUrl={serverUrl}
|
||||
token={data?.livekit?.token}
|
||||
connect={true}
|
||||
connect={isConnectionWarmedUp}
|
||||
audio={userConfig.audioEnabled}
|
||||
video={
|
||||
userConfig.videoEnabled && {
|
||||
@@ -124,16 +219,34 @@ export const Conference = ({
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
onError={(e) => {
|
||||
posthog.captureException(e)
|
||||
}}
|
||||
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 />
|
||||
{showInviteDialog && (
|
||||
<InviteDialog
|
||||
isOpen={showInviteDialog}
|
||||
onOpenChange={setShowInviteDialog}
|
||||
roomId={roomId}
|
||||
onClose={() => setShowInviteDialog(false)}
|
||||
/>
|
||||
)}
|
||||
<MediaDeviceErrorAlert
|
||||
{...mediaDeviceError}
|
||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||
/>
|
||||
</LiveKitRoom>
|
||||
</Screen>
|
||||
</QueryAware>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { Div, Button, type DialogProps, P } from '@/primitives'
|
||||
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
|
||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import { Heading, Dialog } from 'react-aria-components'
|
||||
import { Text, text } from '@/primitives/Text'
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
RiFileCopyLine,
|
||||
RiSpam2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
|
||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
|
||||
|
||||
// fixme - extract in a proper primitive this dialog without overlay
|
||||
const StyledRACDialog = styled(Dialog, {
|
||||
@@ -34,24 +39,27 @@ const StyledRACDialog = styled(Dialog, {
|
||||
},
|
||||
})
|
||||
|
||||
export const InviteDialog = ({
|
||||
roomId,
|
||||
...dialogProps
|
||||
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const roomUrl = getRouteUrl('room', roomId)
|
||||
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const roomData = useRoomData()
|
||||
const roomUrl = getRouteUrl('room', roomData?.slug)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [isCopied])
|
||||
const telephony = useTelephony()
|
||||
|
||||
const isTelephonyReadyForUse = useMemo(() => {
|
||||
return telephony?.enabled && roomData?.pin_code
|
||||
}, [telephony?.enabled, roomData?.pin_code])
|
||||
|
||||
const {
|
||||
isCopied,
|
||||
copyRoomToClipboard,
|
||||
isRoomUrlCopied,
|
||||
copyRoomUrlToClipboard,
|
||||
} = useCopyRoomToClipboard(roomData)
|
||||
|
||||
return (
|
||||
<StyledRACDialog {...dialogProps}>
|
||||
<StyledRACDialog {...props}>
|
||||
{({ close }) => (
|
||||
<VStack
|
||||
alignItems="left"
|
||||
@@ -60,7 +68,7 @@ export const InviteDialog = ({
|
||||
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
|
||||
{t('shareDialog.heading')}
|
||||
{t('heading')}
|
||||
</Heading>
|
||||
<Div position="absolute" top="5" right="5">
|
||||
<Button
|
||||
@@ -68,7 +76,7 @@ export const InviteDialog = ({
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
dialogProps.onClose?.()
|
||||
props.onClose?.()
|
||||
close()
|
||||
}}
|
||||
aria-label={t('closeDialog')}
|
||||
@@ -76,49 +84,125 @@ export const InviteDialog = ({
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
<P>{t('shareDialog.description')}</P>
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
fullWidth
|
||||
aria-label={t('shareDialog.copy')}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
setIsCopied(true)
|
||||
}}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('shareDialog.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('shareDialog.copyButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<HStack>
|
||||
<P>{t('description')}</P>
|
||||
{isTelephonyReadyForUse ? (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primary.200',
|
||||
borderRadius: '50%',
|
||||
padding: '4px',
|
||||
marginTop: '1rem',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<RiSpam2Fill
|
||||
size={22}
|
||||
<div
|
||||
className={css({
|
||||
fill: 'primary.500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
/>
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && roomUrl && (
|
||||
<Button
|
||||
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||
square
|
||||
size={'sm'}
|
||||
onPress={copyRoomUrlToClipboard}
|
||||
aria-label={t('copyUrl')}
|
||||
tooltip={t('copyUrl')}
|
||||
>
|
||||
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||
{telephony?.internationalPhoneNumber}
|
||||
</Text>
|
||||
<Text as="p" wrap="pretty">
|
||||
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||
{formatPinCode(roomData?.pin_code)}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'secondaryText'}
|
||||
size="sm"
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
style={{
|
||||
justifyContent: 'start',
|
||||
}}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine
|
||||
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||
/>
|
||||
{t('copy')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
{t('shareDialog.permissions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'tertiary'}
|
||||
fullWidth
|
||||
aria-label={t('copy')}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="share-dialog-copy"
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||
{t('copyUrl')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||
<HStack>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primary.200',
|
||||
borderRadius: '50%',
|
||||
padding: '4px',
|
||||
marginTop: '1rem',
|
||||
})}
|
||||
>
|
||||
<RiSpam2Fill
|
||||
size={22}
|
||||
className={css({
|
||||
fill: 'primary.500',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||
{t('permissions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</StyledRACDialog>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { usePreviewTracks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
||||
@@ -27,7 +27,6 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
|
||||
const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
@@ -107,10 +106,10 @@ const Effects = ({
|
||||
}
|
||||
|
||||
export const Join = ({
|
||||
onSubmit,
|
||||
enterRoom,
|
||||
roomId,
|
||||
}: {
|
||||
onSubmit: (choices: LocalUserChoices) => void
|
||||
enterRoom: () => void
|
||||
roomId: string
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
@@ -132,23 +131,14 @@ export const Join = ({
|
||||
saveProcessorSerialized,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const [processor, setProcessor] = useState(
|
||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}, [
|
||||
processor,
|
||||
saveProcessorSerialized,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(processor?.serialize()),
|
||||
])
|
||||
|
||||
const tracks = usePreviewTracks(
|
||||
{
|
||||
audio: { deviceId: audioDeviceId },
|
||||
video: { deviceId: videoDeviceId },
|
||||
video: {
|
||||
deviceId: videoDeviceId,
|
||||
processor:
|
||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
|
||||
},
|
||||
},
|
||||
onError
|
||||
)
|
||||
@@ -192,25 +182,6 @@ export const Join = ({
|
||||
}
|
||||
}, [videoTrack, videoEnabled])
|
||||
|
||||
const enterRoom = useCallback(() => {
|
||||
onSubmit({
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
username,
|
||||
processorSerialized: processor?.serialize(),
|
||||
})
|
||||
}, [
|
||||
onSubmit,
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
audioDeviceId,
|
||||
videoDeviceId,
|
||||
username,
|
||||
processor,
|
||||
])
|
||||
|
||||
// Room data strategy:
|
||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
||||
@@ -269,16 +240,6 @@ export const Join = ({
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
// This hook is used to setup the persisted user choice processor on initialization.
|
||||
// So it's on purpose that processor is not included in the deps.
|
||||
// We just want to wait for the videoTrack to be loaded to apply the default processor.
|
||||
useEffect(() => {
|
||||
if (processor && videoTrack && !videoTrack.getProcessor()) {
|
||||
videoTrack.setProcessor(processor)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [videoTrack])
|
||||
|
||||
const renderWaitingState = () => {
|
||||
switch (status) {
|
||||
case ApiLobbyStatus.TIMEOUT:
|
||||
@@ -453,7 +414,12 @@ export const Join = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
|
||||
<Effects
|
||||
videoTrack={videoTrack}
|
||||
onSubmit={(processor) =>
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HStack justify="center" padding={1.5}>
|
||||
<SelectToggleDevice
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { Participant } from 'livekit-client'
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
export const useLowerHandParticipant = () => {
|
||||
const data = useRoomData()
|
||||
@@ -11,8 +10,12 @@ export const useLowerHandParticipant = () => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
const newMetadata = safeParseMetadata(participant.metadata) || {}
|
||||
newMetadata.raised = !newMetadata.raised
|
||||
|
||||
const newAttributes = {
|
||||
...participant.attributes,
|
||||
handRaisedAt: '',
|
||||
}
|
||||
|
||||
return fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
data.livekit.url,
|
||||
@@ -24,7 +27,7 @@ export const useLowerHandParticipant = () => {
|
||||
body: JSON.stringify({
|
||||
room: data.livekit.room,
|
||||
identity: participant.identity,
|
||||
metadata: JSON.stringify(newMetadata),
|
||||
attributes: newAttributes,
|
||||
permission: participant.permissions,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||
import { Button, Div, Text } from '@/primitives'
|
||||
import { Bold, Button, Div, Text } from '@/primitives'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { formatPinCode } from '../../utils/telephony'
|
||||
import { useTelephony } from '../hooks/useTelephony'
|
||||
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
|
||||
|
||||
export const Info = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
const data = useRoomData()
|
||||
const roomUrl = getRouteUrl('room', data?.slug)
|
||||
|
||||
const telephony = useTelephony()
|
||||
|
||||
const isTelephonyReadyForUse = useMemo(() => {
|
||||
return telephony?.enabled && data?.pin_code
|
||||
}, [telephony?.enabled, data?.pin_code])
|
||||
|
||||
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
@@ -41,17 +43,34 @@ export const Info = () => {
|
||||
>
|
||||
{t('roomInformation.title')}
|
||||
</Text>
|
||||
<Text as="p" variant="xsNote" wrap="pretty">
|
||||
{roomUrl}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
gap: '0.15rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<Text as="p" variant="xsNote" wrap="pretty">
|
||||
{roomUrl.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
{isTelephonyReadyForUse && (
|
||||
<>
|
||||
<Text as="p" variant="xsNote" wrap="pretty">
|
||||
<Bold>{t('roomInformation.phone.call')}</Bold> (
|
||||
{telephony?.country}) {telephony?.internationalPhoneNumber}
|
||||
</Text>
|
||||
<Text as="p" variant="xsNote" wrap="pretty">
|
||||
<Bold>{t('roomInformation.phone.pinCode')}</Bold>{' '}
|
||||
{formatPinCode(data?.pin_code)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||
aria-label={t('roomInformation.button.ariaLabel')}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
setIsCopied(true)
|
||||
}}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="copy-info-sidepannel"
|
||||
style={{
|
||||
marginLeft: '-8px',
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { useRaisedHand } from '../hooks/useRaisedHand'
|
||||
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
@@ -97,6 +97,10 @@ export const ParticipantTile: (
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
|
||||
return (
|
||||
@@ -141,24 +145,32 @@ export const ParticipantTile: (
|
||||
style={{
|
||||
padding: '0.1rem 0.25rem',
|
||||
backgroundColor:
|
||||
isHandRaised && !isScreenShare ? 'white' : undefined,
|
||||
isHandRaised && !isScreenShare
|
||||
? firstInQueue
|
||||
? '#fde047'
|
||||
: 'white'
|
||||
: undefined,
|
||||
color:
|
||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||
transition: 'background 200ms ease, color 400ms ease',
|
||||
}}
|
||||
>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
size={16}
|
||||
style={{
|
||||
marginRight: '0.4rem',
|
||||
marginLeft: '0.1rem',
|
||||
minWidth: '16px',
|
||||
animationDuration: '300ms',
|
||||
animationName: 'wave_hand',
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
||||
import { Button } from '@/primitives'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { RiPhoneFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ConnectionState } from 'livekit-client'
|
||||
@@ -21,9 +20,6 @@ export const LeaveButton = () => {
|
||||
.catch((e) =>
|
||||
console.error('An error occurred while disconnecting:', e)
|
||||
)
|
||||
.finally(() => {
|
||||
navigateTo('feedback')
|
||||
})
|
||||
}}
|
||||
data-attr="controls-leave"
|
||||
>
|
||||
|
||||
+9
-5
@@ -11,7 +11,6 @@ import { WaitingParticipantListItem } from './WaitingParticipantListItem'
|
||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
||||
|
||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||
export const ParticipantsList = () => {
|
||||
@@ -35,10 +34,15 @@ export const ParticipantsList = () => {
|
||||
...sortedRemoteParticipants,
|
||||
]
|
||||
|
||||
const raisedHandParticipants = participants.filter((participant) => {
|
||||
const data = safeParseMetadata(participant.metadata)
|
||||
return data.raised
|
||||
})
|
||||
const raisedHandParticipants = participants
|
||||
.filter((participant) => !!participant.attributes.handRaisedAt)
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.attributes.handRaisedAt)
|
||||
const dateB = new Date(b.attributes.handRaisedAt)
|
||||
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
|
||||
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
|
||||
return timeA - timeB
|
||||
})
|
||||
|
||||
const { waitingParticipants, handleParticipantEntry } =
|
||||
useWaitingParticipants()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useTelephony } from './useTelephony'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
|
||||
const COPY_SUCCESS_TIMEOUT = 3000
|
||||
|
||||
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
||||
const telephony = useTelephony()
|
||||
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
|
||||
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const [isRoomUrlCopied, setIsRoomUrlCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCopied) {
|
||||
const timeout = setTimeout(() => setIsCopied(false), COPY_SUCCESS_TIMEOUT)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [isCopied])
|
||||
|
||||
useEffect(() => {
|
||||
if (isRoomUrlCopied) {
|
||||
const timeout = setTimeout(
|
||||
() => setIsRoomUrlCopied(false),
|
||||
COPY_SUCCESS_TIMEOUT
|
||||
)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [isRoomUrlCopied])
|
||||
|
||||
const roomUrl = useMemo(() => {
|
||||
return room?.slug ? getRouteUrl('room', room.slug) : ''
|
||||
}, [room?.slug])
|
||||
|
||||
const hasTelephonyInfo = useMemo(() => {
|
||||
return telephony.enabled && room?.pin_code
|
||||
}, [telephony.enabled, room?.pin_code])
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!roomUrl || !room) return ''
|
||||
if (!hasTelephonyInfo) return roomUrl
|
||||
|
||||
return [
|
||||
t('url', { roomUrl }),
|
||||
t('numberAndPin', {
|
||||
phoneNumber: telephony?.internationalPhoneNumber,
|
||||
pinCode: formatPinCode(room.pin_code),
|
||||
}),
|
||||
].join('\n')
|
||||
}, [roomUrl, hasTelephonyInfo, telephony, room, t])
|
||||
|
||||
const copyRoomToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setIsCopied(true)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const copyRoomUrlToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(roomUrl)
|
||||
setIsRoomUrlCopied(true)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isCopied,
|
||||
copyRoomToClipboard,
|
||||
isRoomUrlCopied,
|
||||
copyRoomUrlToClipboard,
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalytics
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
|
||||
export const useNoiseReductionAvailable = () => {
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.noiseReduction)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
const isMobile = isMobileBrowser()
|
||||
|
||||
@@ -1,23 +1,58 @@
|
||||
import { LocalParticipant, Participant } from 'livekit-client'
|
||||
import { useParticipantInfo } from '@livekit/components-react'
|
||||
import {
|
||||
useParticipantAttribute,
|
||||
useParticipants,
|
||||
} from '@livekit/components-react'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type useRaisedHandProps = {
|
||||
participant: Participant
|
||||
}
|
||||
|
||||
export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
// fixme - refactor this part to rely on attributes
|
||||
const { metadata } = useParticipantInfo({ participant })
|
||||
const parsedMetadata = JSON.parse(metadata ?? '{}')
|
||||
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
|
||||
const { isHandRaised } = useRaisedHand({ participant })
|
||||
|
||||
const toggleRaisedHand = () => {
|
||||
if (isLocal(participant)) {
|
||||
parsedMetadata.raised = !parsedMetadata.raised
|
||||
const localParticipant = participant as LocalParticipant
|
||||
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
|
||||
const participants = useParticipants()
|
||||
|
||||
const positionInQueue = useMemo(() => {
|
||||
if (!isHandRaised) return
|
||||
|
||||
return (
|
||||
participants
|
||||
.filter((p) => !!p.attributes.handRaisedAt)
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.attributes.handRaisedAt)
|
||||
const dateB = new Date(b.attributes.handRaisedAt)
|
||||
return dateA.getTime() - dateB.getTime()
|
||||
})
|
||||
.findIndex((p) => p.identity === participant.identity) + 1
|
||||
)
|
||||
}, [participants, participant, isHandRaised])
|
||||
|
||||
return {
|
||||
positionInQueue,
|
||||
firstInQueue: positionInQueue == 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
|
||||
participant,
|
||||
})
|
||||
|
||||
const isHandRaised = !!handRaisedAtAttribute
|
||||
|
||||
const toggleRaisedHand = async () => {
|
||||
if (!isLocal(participant)) return
|
||||
const localParticipant = participant as LocalParticipant
|
||||
|
||||
const attributes: Record<string, string> = {
|
||||
handRaisedAt: !isHandRaised ? new Date().toISOString() : '',
|
||||
}
|
||||
|
||||
await localParticipant.setAttributes(attributes)
|
||||
}
|
||||
|
||||
return { isHandRaised: parsedMetadata.raised ?? false, toggleRaisedHand }
|
||||
return { isHandRaised, toggleRaisedHand }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
import { useMemo } from 'react'
|
||||
import { parseConfigPhoneNumber } from '../../utils/telephony'
|
||||
|
||||
export const useTelephony = () => {
|
||||
const { data } = useConfig()
|
||||
|
||||
const parsedPhoneNumber = useMemo(() => {
|
||||
return parseConfigPhoneNumber(
|
||||
data?.telephony?.phone_number,
|
||||
data?.telephony?.default_country
|
||||
)
|
||||
}, [data?.telephony])
|
||||
|
||||
return {
|
||||
enabled: data?.telephony?.enabled && parsedPhoneNumber,
|
||||
country: parsedPhoneNumber?.country,
|
||||
internationalPhoneNumber: parsedPhoneNumber?.formatInternational(),
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Button } from '@/primitives'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
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
|
||||
const Heading = styled('h1', {
|
||||
@@ -16,17 +16,25 @@ const Heading = styled('h1', {
|
||||
lineHeight: '2.5rem',
|
||||
letterSpacing: '0',
|
||||
paddingBottom: '2rem',
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
export const FeedbackRoute = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const [, setLocation] = useLocation()
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Center>
|
||||
<VStack>
|
||||
<Heading>{t('feedback.heading')}</Heading>
|
||||
<Heading>
|
||||
{t(
|
||||
`feedback.heading.${searchParams.get('duplicateIdentity') ? 'duplicateIdentity' : 'normal'}`
|
||||
)}
|
||||
</Heading>
|
||||
<HStack>
|
||||
<Button variant="secondary" onPress={() => window.history.back()}>
|
||||
{t('feedback.back')}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { useLocation, useParams } from 'wouter'
|
||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||
import { useUser, UserAware } from '@/features/auth'
|
||||
@@ -10,12 +9,10 @@ import {
|
||||
isRoomValid,
|
||||
normalizeRoomId,
|
||||
} from '@/features/rooms/utils/isRoomValid'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
|
||||
export const Room = () => {
|
||||
const { isLoggedIn } = useUser()
|
||||
const { userChoices: existingUserChoices } = usePersistentUserChoices()
|
||||
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
|
||||
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
|
||||
|
||||
const { roomId } = useParams()
|
||||
const [location, setLocation] = useLocation()
|
||||
@@ -48,10 +45,10 @@ export const Room = () => {
|
||||
return <ErrorScreen />
|
||||
}
|
||||
|
||||
if (!userConfig && !skipJoinScreen) {
|
||||
if (!hasSubmittedEntry && !skipJoinScreen) {
|
||||
return (
|
||||
<UserAware>
|
||||
<Join onSubmit={setUserConfig} roomId={roomId} />
|
||||
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
|
||||
</UserAware>
|
||||
)
|
||||
}
|
||||
@@ -62,10 +59,6 @@ export const Room = () => {
|
||||
initialRoomData={initialRoomData}
|
||||
roomId={roomId}
|
||||
mode={mode}
|
||||
userConfig={{
|
||||
...existingUserChoices,
|
||||
...userConfig,
|
||||
}}
|
||||
/>
|
||||
</UserAware>
|
||||
)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export const safeParseMetadata = (
|
||||
metadataStr: string | null | undefined
|
||||
): Record<string, unknown> => {
|
||||
if (!metadataStr) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(metadataStr)
|
||||
|
||||
// Ensure the result is an object
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
console.warn('Metadata parsed to non-object value:', parsed)
|
||||
return {}
|
||||
}
|
||||
|
||||
return parsed as Record<string, unknown>
|
||||
} catch (error) {
|
||||
console.error('Failed to parse metadata:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CountryCode, parsePhoneNumberWithError } from 'libphonenumber-js'
|
||||
|
||||
export const parseConfigPhoneNumber = (
|
||||
rawPhoneNumber?: string,
|
||||
defaultCountry?: string
|
||||
) => {
|
||||
if (!rawPhoneNumber || !defaultCountry) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return parsePhoneNumberWithError(
|
||||
rawPhoneNumber,
|
||||
defaultCountry as CountryCode
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('Invalid phone number format:', rawPhoneNumber, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPinCode(pinCode?: string) {
|
||||
return pinCode && `${pinCode.replace(/(\d{3})(\d{3})(\d{4})/, '$1 $2 $3')}#`
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
const { t, i18n } = useTranslation('settings')
|
||||
const { user, isLoggedIn, logout } = useUser()
|
||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||
const userDisplay =
|
||||
user?.full_name && user?.email
|
||||
? `${user.full_name} (${user.email})`
|
||||
: user?.email
|
||||
return (
|
||||
<Dialog title={t('dialog.heading')} {...props}>
|
||||
<H lvl={2}>{t('account.heading')}</H>
|
||||
@@ -18,7 +22,7 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||
<P>
|
||||
<Trans
|
||||
i18nKey="settings:account.currentlyLoggedAs"
|
||||
values={{ user: user?.full_name ?? user?.email }}
|
||||
values={{ user: userDisplay }}
|
||||
components={[<Badge />]}
|
||||
/>
|
||||
</P>
|
||||
|
||||
@@ -14,8 +14,8 @@ import { AccountTab } from './tabs/AccountTab'
|
||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
import { GeneralTab } from './tabs/GeneralTab'
|
||||
import { AudioTab } from './tabs/AudioTab'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useRef } from 'react'
|
||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -51,8 +51,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
const { t } = useTranslation('settings')
|
||||
|
||||
const dialogEl = useRef<HTMLDivElement>(null)
|
||||
const { width } = useSize(dialogEl)
|
||||
const isWideScreen = !width || width >= 800 // fixme - hardcoded 50rem in pixel
|
||||
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
||||
|
||||
return (
|
||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||
|
||||
@@ -18,6 +18,10 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
const room = useRoomContext()
|
||||
const { user, isLoggedIn, logout } = useUser()
|
||||
const [name, setName] = useState(room?.localParticipant.name ?? '')
|
||||
const userDisplay =
|
||||
user?.full_name && user?.email
|
||||
? `${user.full_name} (${user.email})`
|
||||
: user?.email
|
||||
|
||||
const handleOnSubmit = () => {
|
||||
if (room) room.localParticipant.setName(name)
|
||||
@@ -37,7 +41,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
value={name}
|
||||
onChange={setName}
|
||||
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>
|
||||
@@ -46,7 +50,7 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
<P>
|
||||
<Trans
|
||||
i18nKey="settings:account.currentlyLoggedAs"
|
||||
values={{ user: user?.full_name || user?.email }}
|
||||
values={{ user: userDisplay }}
|
||||
components={[<Badge />]}
|
||||
/>
|
||||
</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 {
|
||||
@@ -174,7 +174,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
||||
</RowWrapper>
|
||||
{/* 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. */}
|
||||
{!isSafari() && (
|
||||
{!isSafari() ? (
|
||||
<RowWrapper heading={t('audio.speakers.heading')}>
|
||||
<Field
|
||||
type="select"
|
||||
@@ -193,6 +193,13 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
||||
/>
|
||||
<SoundTester />
|
||||
</RowWrapper>
|
||||
) : (
|
||||
<RowWrapper heading={t('audio.speakers.heading')}>
|
||||
<Text variant="warning" margin="md">
|
||||
{t('audio.speakers.safariWarning')}
|
||||
</Text>
|
||||
<div />
|
||||
</RowWrapper>
|
||||
)}
|
||||
{noiseReductionAvailable && (
|
||||
<RowWrapper heading={t('audio.noiseReduction.heading')} beta>
|
||||
|
||||
@@ -12,6 +12,7 @@ export const useKeyboardShortcuts = () => {
|
||||
// Issues might occur. First draft.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const { key, metaKey, ctrlKey } = e
|
||||
if (!key) return
|
||||
const shortcutKey = formatShortcutKey({
|
||||
key,
|
||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||
|
||||
@@ -7,7 +7,7 @@ export const initializeSupportSession = (user: ApiUser) => {
|
||||
if (!Crisp.isCrispInjected()) return
|
||||
const { id, email } = user
|
||||
Crisp.setTokenId(`meet-${id}`)
|
||||
Crisp.user.setEmail(email)
|
||||
if (email) Crisp.user.setEmail(email)
|
||||
}
|
||||
|
||||
export const terminateSupportSession = () => {
|
||||
|
||||
@@ -51,5 +51,9 @@
|
||||
"ariaLabel": "Hinweis schließen",
|
||||
"label": "OK"
|
||||
}
|
||||
},
|
||||
"clipboardContent": {
|
||||
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
|
||||
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Ihre Zugangsdaten",
|
||||
"description": "Senden Sie diesen Link an die Personen, die Sie zum Meeting einladen möchten. Sie können ohne ProConnect teilnehmen.",
|
||||
"copy": "Meeting-Link kopieren",
|
||||
"copied": "Link in die Zwischenablage kopiert",
|
||||
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten."
|
||||
"description": "Teilen Sie diese Informationen mit den Gästen. Sie können dem Meeting beitreten, ohne sich anmelden zu müssen. Dieses Meeting ist dauerhaft und kann wiederverwendet werden.",
|
||||
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten.",
|
||||
"copy": "Informationen kopieren",
|
||||
"copied": "Informationen kopiert",
|
||||
"copyUrl": "Meeting-Link kopieren",
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
},
|
||||
"transcript": {
|
||||
"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": {
|
||||
"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": {
|
||||
"transcript": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"error": {
|
||||
"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": {
|
||||
"title": "Aufzeichnung abgelaufen",
|
||||
@@ -19,6 +19,10 @@
|
||||
"title": "Ihre Aufzeichnung ist bereit!",
|
||||
"body": "Aufzeichnung des Treffens <b>{{room}}</b> vom {{created_at}}.",
|
||||
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
|
||||
"button": "Herunterladen"
|
||||
"button": "Herunterladen",
|
||||
"warning": {
|
||||
"title": "Linkfreigabe deaktiviert",
|
||||
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"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",
|
||||
"back": "Dem Meeting erneut beitreten"
|
||||
},
|
||||
@@ -50,12 +53,29 @@
|
||||
},
|
||||
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
|
||||
"shareDialog": {
|
||||
"copy": "Meeting-Link kopieren",
|
||||
"copyButton": "Link kopieren",
|
||||
"copied": "Link in die Zwischenablage kopiert",
|
||||
"copy": "Informationen kopieren",
|
||||
"copyUrl": "Link kopieren",
|
||||
"copied": "Informationen kopiert",
|
||||
"heading": "Ihr Meeting ist bereit",
|
||||
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
|
||||
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten."
|
||||
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"createRoom": {
|
||||
@@ -210,9 +230,13 @@
|
||||
"roomInformation": {
|
||||
"title": "Verbindungsinformationen",
|
||||
"button": {
|
||||
"ariaLabel": "Kopiere deine Meeting-Adresse",
|
||||
"copy": "Adresse kopieren",
|
||||
"copied": "Adresse kopiert"
|
||||
"ariaLabel": "Kopieren Sie die Informationen aus Ihrer Besprechung",
|
||||
"copy": "Informationen kopieren",
|
||||
"copied": "Informationen kopiert"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -375,6 +399,12 @@
|
||||
},
|
||||
"any": {
|
||||
"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": {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"heading": "Konto",
|
||||
"youAreNotLoggedIn": "Sie sind nicht angemeldet.",
|
||||
"nameLabel": "Ihr Name",
|
||||
"authentication": "Authentifizierung"
|
||||
"authentication": "Authentifizierung",
|
||||
"nameError": "Ihr Name darf nicht leer sein"
|
||||
},
|
||||
"audio": {
|
||||
"microphone": {
|
||||
@@ -24,7 +25,8 @@
|
||||
"heading": "Lautsprecher",
|
||||
"label": "Wählen Sie Ihre Audioausgabe",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -51,5 +51,9 @@
|
||||
"ariaLabel": "Close the suggestion",
|
||||
"label": "OK"
|
||||
}
|
||||
},
|
||||
"clipboardContent": {
|
||||
"url": "To join the video conference, click on this link: {{roomUrl}}",
|
||||
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Your connection details",
|
||||
"description": "Send this link to the people you want to invite to the meeting. They will be able to join without ProConnect.",
|
||||
"copy": "Copy the meeting link",
|
||||
"copied": "Link copied to clipboard",
|
||||
"permissions": "People with this link do not need your permission to join this meeting."
|
||||
"description": "Share this information with the guests. They will be able to join the meeting without needing to sign in. This meeting is permanent and can be reused.",
|
||||
"permissions": "People with this link do not need your permission to join this meeting.",
|
||||
"copy": "Copy information",
|
||||
"copied": "Information copied to clipboard",
|
||||
"copyUrl": "Copy the meeting link",
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
},
|
||||
"transcript": {
|
||||
"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": {
|
||||
"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": {
|
||||
"transcript": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"error": {
|
||||
"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": {
|
||||
"title": "Recording expired",
|
||||
@@ -19,6 +19,10 @@
|
||||
"title": "Your recording is ready!",
|
||||
"body": "Recording of the meeting <b>{{room}}</b> from {{created_at}}.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"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",
|
||||
"back": "Rejoin the meeting"
|
||||
},
|
||||
@@ -50,12 +53,29 @@
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
"copy": "Copy the meeting link",
|
||||
"copyButton": "Copy link",
|
||||
"copied": "Link copied to clipboard",
|
||||
"copy": "Copy information",
|
||||
"copyUrl": "Copy link",
|
||||
"copied": "Information copied to clipboard",
|
||||
"heading": "Your meeting is ready",
|
||||
"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.",
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"createRoom": {
|
||||
@@ -210,9 +230,13 @@
|
||||
"roomInformation": {
|
||||
"title": "Connection Information",
|
||||
"button": {
|
||||
"ariaLabel": "Copy your meeting address",
|
||||
"copy": "Copy address",
|
||||
"copied": "Address copied"
|
||||
"ariaLabel": "Copy the information from your meeting",
|
||||
"copy": "Copy information",
|
||||
"copied": "Information copied"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Call:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -375,6 +399,12 @@
|
||||
},
|
||||
"any": {
|
||||
"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": {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"heading": "Account",
|
||||
"youAreNotLoggedIn": "You are not logged in.",
|
||||
"nameLabel": "Your Name",
|
||||
"authentication": "Authentication"
|
||||
"authentication": "Authentication",
|
||||
"nameError": "Your name cannot be empty"
|
||||
},
|
||||
"audio": {
|
||||
"microphone": {
|
||||
@@ -24,7 +25,8 @@
|
||||
"heading": "Speakers",
|
||||
"label": "Select your audio output",
|
||||
"test": "Test",
|
||||
"ongoingTest": "Testing sound…"
|
||||
"ongoingTest": "Testing sound…",
|
||||
"safariWarning": "Speaker selection is not available yet on Safari due to browser limitations."
|
||||
},
|
||||
"permissionsRequired": "Permissions required"
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"datagouv": "data.gouv.fr",
|
||||
"legalsTerms": "Mentions légales",
|
||||
"data": "Données personnelles et cookie",
|
||||
"accessibility": "Accessibilités : non conforme",
|
||||
"accessibility": "Accessibilité : non conforme",
|
||||
"ariaLabel": "nouvelle fenêtre",
|
||||
"codeAnnotation": "Notre code est ouvert et disponible sur ce",
|
||||
"code": "dépôt de code Open Source",
|
||||
@@ -51,5 +51,9 @@
|
||||
"ariaLabel": "Fermer la suggestion",
|
||||
"label": "OK"
|
||||
}
|
||||
},
|
||||
"clipboardContent": {
|
||||
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
|
||||
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Vos informations de connexion",
|
||||
"description": "Envoyez ce lien aux personnes que vous souhaitez inviter à la réunion. Ils pourront la rejoindre sans ProConnect.",
|
||||
"copy": "Copier le lien de la réunion",
|
||||
"copied": "Lien copié dans le presse-papiers",
|
||||
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
||||
"description": "Partagez ces informations avec les invités. Ils pourront rejoindre la réunion sans avoir besoin de se connecter. Cette réunion est permanente et peut être réutilisée.",
|
||||
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
|
||||
"copy": "Copier les informations",
|
||||
"copied": "Copiées dans le presse-papiers",
|
||||
"copyUrl": "Copier le lien de la réunion",
|
||||
"phone": {
|
||||
"call": "Appelez le :",
|
||||
"pinCode": "Code :"
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
},
|
||||
"transcript": {
|
||||
"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": {
|
||||
"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": {
|
||||
"transcript": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"error": {
|
||||
"title": "Enregistrement indisponible",
|
||||
"body": "Cet enregistrement est introuvable ou a été supprimé."
|
||||
"body": "L’enregistrement est introuvable. Seul l’organisateur de la réunion peut y accéder. N’hésitez pas à le contacter si besoin."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Enregistrement expiré",
|
||||
@@ -19,6 +19,10 @@
|
||||
"title": "Votre enregistrement est prêt !",
|
||||
"body": "Enregistrement de la réunion <b>{{room}}</b> du {{created_at}}.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"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",
|
||||
"back": "Réintégrer la réunion"
|
||||
},
|
||||
@@ -50,12 +53,29 @@
|
||||
},
|
||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||
"shareDialog": {
|
||||
"copy": "Copier le lien de la réunion",
|
||||
"copyButton": "Copier le lien",
|
||||
"copied": "Lien copié dans le presse-papiers",
|
||||
"copy": "Copier les informations",
|
||||
"copyUrl": "Copier le lien",
|
||||
"copied": "Copiées dans le presse-papiers",
|
||||
"heading": "Votre réunion est prête",
|
||||
"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."
|
||||
"description": "Partagez ces informations 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.",
|
||||
"phone": {
|
||||
"call": "Appelez le :",
|
||||
"pinCode": "Code :"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"createRoom": {
|
||||
@@ -210,9 +230,13 @@
|
||||
"roomInformation": {
|
||||
"title": "Informations de connexions",
|
||||
"button": {
|
||||
"ariaLabel": "Copier l'adresse de votre réunion",
|
||||
"copy": "Copier l'adresse",
|
||||
"copied": "Adresse copiée"
|
||||
"ariaLabel": "Copier les informations de votre réunion",
|
||||
"copy": "Copier les informations",
|
||||
"copied": "Informations copiées"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Appelez le :",
|
||||
"pinCode": "Code :"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -375,6 +399,12 @@
|
||||
},
|
||||
"any": {
|
||||
"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": {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"heading": "Compte",
|
||||
"youAreNotLoggedIn": "Vous n'êtes pas connecté.",
|
||||
"nameLabel": "Votre Nom",
|
||||
"authentication": "Authentification"
|
||||
"authentication": "Authentification",
|
||||
"nameError": "Votre Nom ne peut pas être vide"
|
||||
},
|
||||
"audio": {
|
||||
"microphone": {
|
||||
@@ -24,7 +25,8 @@
|
||||
"heading": "Haut-parleurs",
|
||||
"label": "Sélectionner votre sortie audio",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -50,5 +50,9 @@
|
||||
"ariaLabel": "Sluit de suggestie",
|
||||
"label": "OK"
|
||||
}
|
||||
},
|
||||
"clipboardContent": {
|
||||
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
|
||||
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Uw verbindingsgegevens",
|
||||
"description": "Stuur deze link naar de mensen die u wilt uitnodigen voor de vergadering. Zij kunnen deelnemen zonder ProConnect.",
|
||||
"copy": "Kopieer de vergaderlink",
|
||||
"copied": "Link gekopieerd naar klembord",
|
||||
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
|
||||
"description": "Deel deze informatie met de genodigden. Zij kunnen deelnemen aan de vergadering zonder zich aan te melden. Deze vergadering is permanent en kan hergebruikt worden.",
|
||||
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
|
||||
"copy": "Informatie kopiëren",
|
||||
"copied": "Informatie gekopieerd",
|
||||
"copyUrl": "Kopieer de vergaderlink",
|
||||
"phone": {
|
||||
"call": "Bel:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
"introSlider": {
|
||||
"previous": {
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
},
|
||||
"transcript": {
|
||||
"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": {
|
||||
"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": {
|
||||
"transcript": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user